From cacea729eb996b32f60da7c11ec0663f8df6cc31 Mon Sep 17 00:00:00 2001 From: Odeyalo <61063611+justJavaProgrammer@users.noreply.github.com> Date: Wed, 21 Aug 2024 19:59:32 +0300 Subject: [PATCH] [FEATURE] Added endpoint to seek current playback position (#73) Added HTTP endpoint to seek playback position to specified. --- .../config/PlayerOperationsConfiguration.java | 6 +- .../connect/controller/PlayerController.java | 9 + .../GlobalExceptionHandlerController.java | 32 +++ .../InvalidSeekPositionException.java | 26 +++ .../MissingPlayableItemException.java | 15 ++ .../SeekPositionExceedDurationException.java | 15 ++ ...pportedSeekPositionPrecisionException.java | 31 +++ .../connect/model/CurrentPlayerState.java | 29 ++- .../sonata/connect/model/PlayableItem.java | 3 + .../service/player/BasicPlayerOperations.java | 17 +- .../player/DefaultPlayerOperations.java | 32 +-- ...entPublisherPlayerOperationsDecorator.java | 21 +- .../connect/service/player/SeekPosition.java | 42 ++++ .../web/resolver/SeekPositionResolver.java | 59 ++++++ .../CurrentPlayerStateEndpointTest.java | 15 +- .../controller/SeekPositionEndpointTest.java | 186 ++++++++++++++++++ .../connect/model/SeekToPositionTest.java | 56 ++++++ ...ublisherPlayerOperationsDecoratorTest.java | 75 ++++++- .../player/SeekToPositionOperationTest.java | 78 ++++++++ ...efaultPlayerOperationsTestableBuilder.java | 4 - .../java/testing/faker/PlayableItemFaker.java | 2 +- .../testing/faker/TrackItemEntityFaker.java | 5 + 22 files changed, 694 insertions(+), 64 deletions(-) create mode 100644 src/main/java/com/odeyalo/sonata/connect/exception/InvalidSeekPositionException.java create mode 100644 src/main/java/com/odeyalo/sonata/connect/exception/MissingPlayableItemException.java create mode 100644 src/main/java/com/odeyalo/sonata/connect/exception/SeekPositionExceedDurationException.java create mode 100644 src/main/java/com/odeyalo/sonata/connect/exception/UnsupportedSeekPositionPrecisionException.java create mode 100644 src/main/java/com/odeyalo/sonata/connect/service/player/SeekPosition.java create mode 100644 src/main/java/com/odeyalo/sonata/connect/support/web/resolver/SeekPositionResolver.java create mode 100644 src/test/java/com/odeyalo/sonata/connect/controller/SeekPositionEndpointTest.java create mode 100644 src/test/java/com/odeyalo/sonata/connect/model/SeekToPositionTest.java create mode 100644 src/test/java/com/odeyalo/sonata/connect/service/player/SeekToPositionOperationTest.java diff --git a/src/main/java/com/odeyalo/sonata/connect/config/PlayerOperationsConfiguration.java b/src/main/java/com/odeyalo/sonata/connect/config/PlayerOperationsConfiguration.java index b33b0d4..45ed3e6 100644 --- a/src/main/java/com/odeyalo/sonata/connect/config/PlayerOperationsConfiguration.java +++ b/src/main/java/com/odeyalo/sonata/connect/config/PlayerOperationsConfiguration.java @@ -5,7 +5,6 @@ import com.odeyalo.sonata.connect.service.player.EventPublisherDeviceOperationsDecorator; import com.odeyalo.sonata.connect.service.player.EventPublisherPlayerOperationsDecorator; import com.odeyalo.sonata.connect.service.player.sync.PlayerSynchronizationManager; -import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; @@ -16,9 +15,8 @@ public class PlayerOperationsConfiguration { @Bean @Primary public EventPublisherPlayerOperationsDecorator eventPublisherPlayerOperationsDecorator(BasicPlayerOperations delegate, - PlayerSynchronizationManager synchronizationManager, - @Qualifier("eventPublisherDeviceOperations") DeviceOperations deviceOperations) { - return new EventPublisherPlayerOperationsDecorator(delegate, synchronizationManager, deviceOperations); + PlayerSynchronizationManager synchronizationManager) { + return new EventPublisherPlayerOperationsDecorator(delegate, synchronizationManager); } @Bean diff --git a/src/main/java/com/odeyalo/sonata/connect/controller/PlayerController.java b/src/main/java/com/odeyalo/sonata/connect/controller/PlayerController.java index 48c4b05..e621857 100644 --- a/src/main/java/com/odeyalo/sonata/connect/controller/PlayerController.java +++ b/src/main/java/com/odeyalo/sonata/connect/controller/PlayerController.java @@ -8,6 +8,7 @@ import com.odeyalo.sonata.connect.model.Volume; import com.odeyalo.sonata.connect.service.player.BasicPlayerOperations; import com.odeyalo.sonata.connect.service.player.PlayCommandContext; +import com.odeyalo.sonata.connect.service.player.SeekPosition; import com.odeyalo.sonata.connect.service.support.mapper.Converter; import com.odeyalo.sonata.connect.service.support.mapper.dto.CurrentPlayerState2PlayerStateDtoConverter; import com.odeyalo.sonata.connect.support.web.HttpStatus; @@ -77,4 +78,12 @@ public Mono> changePlayerVolume(@NotNull final Volume volume, .subscribeOn(Schedulers.boundedElastic()) .map(it -> HttpStatus.default204Response()); } + + @PutMapping(value = "/seek", produces = MediaType.APPLICATION_JSON_VALUE) + public Mono> seekPlaybackPosition(@NotNull final User user, + @NotNull final SeekPosition seekPosition) { + + return playerOperations.seekToPosition(user, seekPosition) + .thenReturn(HttpStatus.default204Response()); + } } diff --git a/src/main/java/com/odeyalo/sonata/connect/exception/GlobalExceptionHandlerController.java b/src/main/java/com/odeyalo/sonata/connect/exception/GlobalExceptionHandlerController.java index b8aed35..5609155 100644 --- a/src/main/java/com/odeyalo/sonata/connect/exception/GlobalExceptionHandlerController.java +++ b/src/main/java/com/odeyalo/sonata/connect/exception/GlobalExceptionHandlerController.java @@ -77,4 +77,36 @@ public ResponseEntity handleMissingRequestParameterException(final MissingReq return ResponseEntity.badRequest() .body(ExceptionMessage.of(ex.getMessage())); } + + @ExceptionHandler(MissingPlayableItemException.class) + public ResponseEntity handleMissingPlayableItemException(final MissingPlayableItemException ex) { + final var body = ReasonCodeAwareExceptionMessage.of(ex.getReasonCode(), "Player command error: no item is playing"); + + return ResponseEntity.badRequest() + .body(body); + } + + @ExceptionHandler(InvalidSeekPositionException.class) + public ResponseEntity handleInvalidSeekPositionException(final InvalidSeekPositionException ex) { + final var body = ReasonCodeAwareExceptionMessage.of(ex.getReasonCode(), "Player command error: position must be positive"); + + return ResponseEntity.badRequest() + .body(body); + } + + @ExceptionHandler(SeekPositionExceedDurationException.class) + public ResponseEntity handleSeekPositionExceedDurationException(final SeekPositionExceedDurationException ex) { + final var body = ReasonCodeAwareExceptionMessage.of(ex.getReasonCode(), "Player command error: position cannot be greater than track duration"); + + return ResponseEntity.badRequest() + .body(body); + } + + @ExceptionHandler(UnsupportedSeekPositionPrecisionException.class) + public ResponseEntity handleUnsupportedSeekPositionPrecisionException(final UnsupportedSeekPositionPrecisionException ex) { + final var body = ReasonCodeAwareExceptionMessage.of(ex.getReasonCode(), "Player command error: unsupported precision used. Supported case insensitive: MILLIS, SECONDS."); + + return ResponseEntity.badRequest() + .body(body); + } } diff --git a/src/main/java/com/odeyalo/sonata/connect/exception/InvalidSeekPositionException.java b/src/main/java/com/odeyalo/sonata/connect/exception/InvalidSeekPositionException.java new file mode 100644 index 0000000..d467f47 --- /dev/null +++ b/src/main/java/com/odeyalo/sonata/connect/exception/InvalidSeekPositionException.java @@ -0,0 +1,26 @@ +package com.odeyalo.sonata.connect.exception; + +public final class InvalidSeekPositionException extends PlayerCommandException { + public static final String REASON_CODE = "invalid_position"; + public static final String DESCRIPTION = "Invalid seek position supplied"; + + private InvalidSeekPositionException() { + super(DESCRIPTION, REASON_CODE); + } + + private InvalidSeekPositionException(final String message) { + super(message, REASON_CODE); + } + + private InvalidSeekPositionException(final String message, final Throwable cause) { + super(message, REASON_CODE, cause); + } + + public static InvalidSeekPositionException defaultException() { + return new InvalidSeekPositionException(); + } + + public static InvalidSeekPositionException withMessage(String message) { + return new InvalidSeekPositionException(message); + } +} diff --git a/src/main/java/com/odeyalo/sonata/connect/exception/MissingPlayableItemException.java b/src/main/java/com/odeyalo/sonata/connect/exception/MissingPlayableItemException.java new file mode 100644 index 0000000..6ca4f6d --- /dev/null +++ b/src/main/java/com/odeyalo/sonata/connect/exception/MissingPlayableItemException.java @@ -0,0 +1,15 @@ +package com.odeyalo.sonata.connect.exception; + +import org.jetbrains.annotations.NotNull; + +public final class MissingPlayableItemException extends PlayerCommandException{ + public static final String REASON_CODE = "playable_item_required"; + + public MissingPlayableItemException(@NotNull final String message) { + super(message, REASON_CODE); + } + + public MissingPlayableItemException(@NotNull final String message, @NotNull final Throwable cause) { + super(message, REASON_CODE, cause); + } +} diff --git a/src/main/java/com/odeyalo/sonata/connect/exception/SeekPositionExceedDurationException.java b/src/main/java/com/odeyalo/sonata/connect/exception/SeekPositionExceedDurationException.java new file mode 100644 index 0000000..011bb47 --- /dev/null +++ b/src/main/java/com/odeyalo/sonata/connect/exception/SeekPositionExceedDurationException.java @@ -0,0 +1,15 @@ +package com.odeyalo.sonata.connect.exception; + +import org.jetbrains.annotations.NotNull; + +public final class SeekPositionExceedDurationException extends PlayerCommandException { + public static final String REASON_CODE = "seek_position_exceed"; + + public SeekPositionExceedDurationException(@NotNull final String message) { + super(message, REASON_CODE); + } + + public SeekPositionExceedDurationException(@NotNull final String message, @NotNull final Throwable cause) { + super(message, REASON_CODE, cause); + } +} diff --git a/src/main/java/com/odeyalo/sonata/connect/exception/UnsupportedSeekPositionPrecisionException.java b/src/main/java/com/odeyalo/sonata/connect/exception/UnsupportedSeekPositionPrecisionException.java new file mode 100644 index 0000000..8954ea4 --- /dev/null +++ b/src/main/java/com/odeyalo/sonata/connect/exception/UnsupportedSeekPositionPrecisionException.java @@ -0,0 +1,31 @@ +package com.odeyalo.sonata.connect.exception; + +import lombok.EqualsAndHashCode; +import lombok.Value; +import org.jetbrains.annotations.NotNull; + +@Value +@EqualsAndHashCode(callSuper = true) +public class UnsupportedSeekPositionPrecisionException extends PlayerCommandException { + + private static final String REASON_CODE = "unsupported_precision"; + private static final String DEFAULT_DESCRIPTION = "Seek position precision of received type is not supported"; + + private final String receivedPrecision; + + public UnsupportedSeekPositionPrecisionException(final String receivedPrecision) { + super(DEFAULT_DESCRIPTION, REASON_CODE); + this.receivedPrecision = receivedPrecision; + } + + public UnsupportedSeekPositionPrecisionException(@NotNull final String message, + @NotNull final String receivedPrecision) { + super(message, REASON_CODE); + this.receivedPrecision = receivedPrecision; + } + + public UnsupportedSeekPositionPrecisionException(final String message, final String receivedPrecision, final Throwable cause) { + super(message, REASON_CODE, cause); + this.receivedPrecision = receivedPrecision; + } +} diff --git a/src/main/java/com/odeyalo/sonata/connect/model/CurrentPlayerState.java b/src/main/java/com/odeyalo/sonata/connect/model/CurrentPlayerState.java index c4c0e27..b5f8c6f 100644 --- a/src/main/java/com/odeyalo/sonata/connect/model/CurrentPlayerState.java +++ b/src/main/java/com/odeyalo/sonata/connect/model/CurrentPlayerState.java @@ -1,13 +1,13 @@ package com.odeyalo.sonata.connect.model; +import com.odeyalo.sonata.connect.exception.MissingPlayableItemException; +import com.odeyalo.sonata.connect.exception.SeekPositionExceedDurationException; +import com.odeyalo.sonata.connect.service.player.SeekPosition; import com.odeyalo.sonata.connect.service.player.TargetDeactivationDevice; import com.odeyalo.sonata.connect.service.player.TargetDevice; import com.odeyalo.sonata.connect.support.time.Clock; import com.odeyalo.sonata.connect.support.time.JavaClock; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Value; -import lombok.With; +import lombok.*; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -28,6 +28,7 @@ public class CurrentPlayerState { RepeatState repeatState = RepeatState.OFF; @NotNull @Builder.Default + @With(value = AccessLevel.PRIVATE) ShuffleMode shuffleState = ShuffleMode.OFF; @Builder.Default long progressMs = -1L; @@ -182,6 +183,26 @@ public CurrentPlayerState pause() { return this; } + @NotNull + public CurrentPlayerState switchShuffleMode(@NotNull final ShuffleMode shuffleMode) { + return withShuffleState(shuffleMode); + } + + @NotNull + public CurrentPlayerState seekTo(@NotNull final SeekPosition seekPosition) { + + if ( playableItem == null ) { + throw new MissingPlayableItemException("Seek command requires playable active"); + } + + if ( seekPosition.exceeds(playableItem.getDuration()) ) { + throw new SeekPositionExceedDurationException("Position cannot be greater than item duration"); + } + + return withProgressMs(seekPosition.posMs()) + .withPlayStartTime(clock.currentTimeMillis()); + } + private long getCurrentProgressMs() { return progressMs + computeElapsedTime(); } diff --git a/src/main/java/com/odeyalo/sonata/connect/model/PlayableItem.java b/src/main/java/com/odeyalo/sonata/connect/model/PlayableItem.java index e003ada..72abb2f 100644 --- a/src/main/java/com/odeyalo/sonata/connect/model/PlayableItem.java +++ b/src/main/java/com/odeyalo/sonata/connect/model/PlayableItem.java @@ -17,6 +17,9 @@ public interface PlayableItem { @NotNull ContextUri getContextUri(); + /** + * @return duration of this item + */ @NotNull PlayableItemDuration getDuration(); } \ No newline at end of file diff --git a/src/main/java/com/odeyalo/sonata/connect/service/player/BasicPlayerOperations.java b/src/main/java/com/odeyalo/sonata/connect/service/player/BasicPlayerOperations.java index 8f7965c..e0ab3d1 100644 --- a/src/main/java/com/odeyalo/sonata/connect/service/player/BasicPlayerOperations.java +++ b/src/main/java/com/odeyalo/sonata/connect/service/player/BasicPlayerOperations.java @@ -53,9 +53,6 @@ default Mono createState(@NotNull User user) { @NotNull Mono changeShuffle(@NotNull User user, @NotNull ShuffleMode shuffleMode); - @NotNull - DeviceOperations getDeviceOperations(); - /** * Start or resume the track. * If track not specified then currently playing track starts to play @@ -83,11 +80,21 @@ Mono playOrResume(@NotNull User user, @NotNull Mono changeVolume(@NotNull User user, @NotNull Volume volume); + /** + * Seek the player's position to specified + * @param user - owner of the player + * @param position - position to seek position to + * @return - updated player state + */ + @NotNull + Mono seekToPosition(@NotNull User user, + @NotNull SeekPosition position); + /** * Alias for #changeShuffle(User, true) method call */ @NotNull - default Mono enableShuffle(User user) { + default Mono enableShuffle(@NotNull final User user) { return changeShuffle(user, ShuffleMode.ENABLED); } @@ -95,7 +102,7 @@ default Mono enableShuffle(User user) { * Alias for #changeShuffle(User, false) method call */ @NotNull - default Mono disableShuffle(User user) { + default Mono disableShuffle(@NotNull final User user) { return changeShuffle(user, ShuffleMode.OFF); } } diff --git a/src/main/java/com/odeyalo/sonata/connect/service/player/DefaultPlayerOperations.java b/src/main/java/com/odeyalo/sonata/connect/service/player/DefaultPlayerOperations.java index 6c98fcd..a7e655d 100644 --- a/src/main/java/com/odeyalo/sonata/connect/service/player/DefaultPlayerOperations.java +++ b/src/main/java/com/odeyalo/sonata/connect/service/player/DefaultPlayerOperations.java @@ -1,6 +1,5 @@ package com.odeyalo.sonata.connect.service.player; -import com.odeyalo.sonata.connect.exception.NoActiveDeviceException; import com.odeyalo.sonata.connect.model.*; import com.odeyalo.sonata.connect.service.player.handler.PauseCommandHandlerDelegate; import com.odeyalo.sonata.connect.service.player.handler.PlayCommandHandlerDelegate; @@ -18,7 +17,6 @@ @Component @RequiredArgsConstructor public final class DefaultPlayerOperations implements BasicPlayerOperations { - private final DeviceOperations deviceOperations; private final PlayCommandHandlerDelegate playCommandHandlerDelegate; private final PauseCommandHandlerDelegate pauseCommandHandlerDelegate; private final CurrentPlayerState2CurrentlyPlayingPlayerStateConverter playerStateConverter; @@ -26,7 +24,6 @@ public final class DefaultPlayerOperations implements BasicPlayerOperations { private final Logger logger = LoggerFactory.getLogger(DefaultPlayerOperations.class); - @Override @NotNull public Mono currentState(@NotNull final User user) { @@ -47,16 +44,10 @@ public Mono currentlyPlayingState(@NotNull final Us public Mono changeShuffle(@NotNull final User user, @NotNull final ShuffleMode shuffleMode) { return playerStateService.loadPlayerState(user) - .map(state -> state.withShuffleState(shuffleMode)) + .map(playerState -> playerState.switchShuffleMode(shuffleMode)) .flatMap(playerStateService::save); } - @Override - @NotNull - public DeviceOperations getDeviceOperations() { - return deviceOperations; - } - @Override @NotNull public Mono playOrResume(@NotNull final User user, @@ -77,24 +68,17 @@ public Mono changeVolume(@NotNull final User user, @NotNull final Volume volume) { return playerStateService.loadPlayerState(user) - .flatMap(playerState -> executeChangeVolumeCommand(playerState, volume)) + .map(playerState -> playerState.changeVolume(volume)) .flatMap(playerStateService::save); } + @Override @NotNull - private static Mono executeChangeVolumeCommand(@NotNull final CurrentPlayerState playerState, - @NotNull final Volume volume) { - - // If we don't have active device, then we don't have connected devices at all - if ( playerState.hasActiveDevice() ) { - return Mono.just( - playerState.changeVolume(volume) - ); - } - - return Mono.defer( - () -> Mono.error(NoActiveDeviceException.defaultException()) - ); + public Mono seekToPosition(@NotNull final User user, + @NotNull final SeekPosition position) { + return playerStateService.loadPlayerState(user) + .map(playerState -> playerState.seekTo(position)) + .flatMap(playerStateService::save); } @NotNull diff --git a/src/main/java/com/odeyalo/sonata/connect/service/player/EventPublisherPlayerOperationsDecorator.java b/src/main/java/com/odeyalo/sonata/connect/service/player/EventPublisherPlayerOperationsDecorator.java index a390ce8..90e4f34 100644 --- a/src/main/java/com/odeyalo/sonata/connect/service/player/EventPublisherPlayerOperationsDecorator.java +++ b/src/main/java/com/odeyalo/sonata/connect/service/player/EventPublisherPlayerOperationsDecorator.java @@ -16,12 +16,10 @@ public final class EventPublisherPlayerOperationsDecorator implements BasicPlayerOperations { private final BasicPlayerOperations delegate; private final PlayerSynchronizationManager synchronizationManager; - private final DeviceOperations deviceOperations; - public EventPublisherPlayerOperationsDecorator(BasicPlayerOperations delegate, PlayerSynchronizationManager synchronizationManager, DeviceOperations deviceOperations) { + public EventPublisherPlayerOperationsDecorator(BasicPlayerOperations delegate, PlayerSynchronizationManager synchronizationManager) { this.delegate = delegate; this.synchronizationManager = synchronizationManager; - this.deviceOperations = deviceOperations; } @Override @@ -42,16 +40,10 @@ public Mono changeShuffle(@NotNull User user, @NotNull Shuff return delegate.changeShuffle(user, shuffleMode); } - @Override - @NotNull - public DeviceOperations getDeviceOperations() { - return deviceOperations; - } - @Override @NotNull public Mono playOrResume(@NotNull final User user, - @Nullable final PlayCommandContext context, + @NotNull final PlayCommandContext context, @Nullable final TargetDevice targetDevice) { return delegate.playOrResume(user, context, targetDevice) .flatMap(it -> publishEvent(it, PLAYER_STATE_UPDATED, user)); @@ -72,6 +64,15 @@ public Mono changeVolume(@NotNull final User user, .flatMap(state -> publishEvent(state, PLAYER_STATE_UPDATED, user)); } + @Override + @NotNull + public Mono seekToPosition(@NotNull final User user, + @NotNull final SeekPosition position) { + return delegate.seekToPosition(user, position) + .map(playerState -> playerState.seekTo(position)) + .flatMap(state -> publishEvent(state, PLAYER_STATE_UPDATED, user)); + } + @NotNull private Mono publishEvent(@NotNull CurrentPlayerState currentPlayerState, @NotNull PlayerEvent.EventType eventType, diff --git a/src/main/java/com/odeyalo/sonata/connect/service/player/SeekPosition.java b/src/main/java/com/odeyalo/sonata/connect/service/player/SeekPosition.java new file mode 100644 index 0000000..7cc7df7 --- /dev/null +++ b/src/main/java/com/odeyalo/sonata/connect/service/player/SeekPosition.java @@ -0,0 +1,42 @@ +package com.odeyalo.sonata.connect.service.player; + +import com.odeyalo.sonata.connect.exception.InvalidSeekPositionException; +import com.odeyalo.sonata.connect.model.PlayableItemDuration; +import org.jetbrains.annotations.NotNull; + +public record SeekPosition(long posMs) { + + public SeekPosition { + if ( posMs < 0 ) { + throw InvalidSeekPositionException.defaultException(); + } + } + + @NotNull + public static SeekPosition fromPrecision(final long position, + @NotNull final Precision precision) { + return switch (precision) { + case SECONDS -> ofSeconds((int) position); + case MILLIS -> ofMillis(position); + }; + } + + @NotNull + public static SeekPosition ofMillis(long millis) { + return new SeekPosition(millis); + } + + @NotNull + public static SeekPosition ofSeconds(int seconds) { + return new SeekPosition(seconds * 1000L); + } + + public boolean exceeds(@NotNull final PlayableItemDuration duration) { + return duration.isExceeded(posMs); + } + + public enum Precision { + SECONDS, + MILLIS + } +} diff --git a/src/main/java/com/odeyalo/sonata/connect/support/web/resolver/SeekPositionResolver.java b/src/main/java/com/odeyalo/sonata/connect/support/web/resolver/SeekPositionResolver.java new file mode 100644 index 0000000..77e7ca0 --- /dev/null +++ b/src/main/java/com/odeyalo/sonata/connect/support/web/resolver/SeekPositionResolver.java @@ -0,0 +1,59 @@ +package com.odeyalo.sonata.connect.support.web.resolver; + +import com.odeyalo.sonata.connect.exception.InvalidSeekPositionException; +import com.odeyalo.sonata.connect.exception.UnsupportedSeekPositionPrecisionException; +import com.odeyalo.sonata.connect.service.player.SeekPosition; +import com.odeyalo.sonata.connect.service.player.SeekPosition.Precision; +import org.apache.commons.lang3.EnumUtils; +import org.apache.commons.lang3.math.NumberUtils; +import org.jetbrains.annotations.NotNull; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.BindingContext; +import org.springframework.web.reactive.result.method.HandlerMethodArgumentResolver; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +import java.util.Map; + +/** + * Resolves a {@link SeekPosition} from the given {@link ServerWebExchange}, + * returns an error if request missing required parameters or parameters are invalid + */ +@Component +public final class SeekPositionResolver implements HandlerMethodArgumentResolver { + private static final String POSITION_QUERY_PARAM = "position"; + private static final String PRECISION_QUERY_PARAM = "precision"; + private static final String DEFAULT_PRECISION = "millis"; + + @Override + public boolean supportsParameter(@NotNull final MethodParameter parameter) { + return parameter.getParameterType().isAssignableFrom(SeekPosition.class); + } + + @Override + @NotNull + public Mono resolveArgument(@NotNull final MethodParameter parameter, + @NotNull final BindingContext bindingContext, + @NotNull final ServerWebExchange exchange) { + + final Map queryParams = exchange.getRequest().getQueryParams().toSingleValueMap(); + + final String positionValue = queryParams.get(POSITION_QUERY_PARAM); + + if ( !NumberUtils.isParsable(positionValue) ) { + return Mono.error(InvalidSeekPositionException.defaultException()); + } + + final String precisionValue = queryParams.getOrDefault(PRECISION_QUERY_PARAM, DEFAULT_PRECISION); + + if ( !EnumUtils.isValidEnumIgnoreCase(Precision.class, precisionValue) ) { + return Mono.error(new UnsupportedSeekPositionPrecisionException(precisionValue)); + } + + final int position = NumberUtils.toInt(positionValue); + final Precision precision = EnumUtils.getEnumIgnoreCase(Precision.class, precisionValue); + + return Mono.just(SeekPosition.fromPrecision(position, precision)); + } +} diff --git a/src/test/java/com/odeyalo/sonata/connect/controller/CurrentPlayerStateEndpointTest.java b/src/test/java/com/odeyalo/sonata/connect/controller/CurrentPlayerStateEndpointTest.java index 426d1a6..1f2ce6b 100644 --- a/src/test/java/com/odeyalo/sonata/connect/controller/CurrentPlayerStateEndpointTest.java +++ b/src/test/java/com/odeyalo/sonata/connect/controller/CurrentPlayerStateEndpointTest.java @@ -4,10 +4,12 @@ import com.odeyalo.sonata.connect.dto.ExceptionMessage; import com.odeyalo.sonata.connect.dto.PlayerStateDto; import com.odeyalo.sonata.connect.entity.*; -import com.odeyalo.sonata.connect.model.*; +import com.odeyalo.sonata.connect.model.DeviceType; +import com.odeyalo.sonata.connect.model.PlayingType; +import com.odeyalo.sonata.connect.model.RepeatState; +import com.odeyalo.sonata.connect.model.TrackItemSpec; import com.odeyalo.sonata.connect.model.track.AlbumSpec; import com.odeyalo.sonata.connect.model.track.ArtistSpec; -import com.odeyalo.sonata.connect.model.track.Image; import com.odeyalo.sonata.connect.repository.PlayerStateRepository; import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; @@ -110,7 +112,7 @@ void prepareData() { .height(300) .width(300) .build() - )) + )) .build() ) .build()) @@ -181,14 +183,11 @@ void shouldContainCurrentlyPlayingType() { @Test - @Disabled("Test class must be rewritten to black box. Wrong progress is returned") void shouldContainProgressMs() { WebTestClient.ResponseSpec responseSpec = sendCurrentPlayerStateRequest(); - PlayerStateDto body = responseSpec.expectBody(PlayerStateDto.class).returnResult().getResponseBody(); - - PlayerStateDtoAssert.forState(body) - .progressMs(0L); + responseSpec.expectBody(PlayerStateDto.class) + .value(body -> assertThat(body.getProgressMs()).isGreaterThanOrEqualTo(0L)); } @Test diff --git a/src/test/java/com/odeyalo/sonata/connect/controller/SeekPositionEndpointTest.java b/src/test/java/com/odeyalo/sonata/connect/controller/SeekPositionEndpointTest.java new file mode 100644 index 0000000..74e2ecb --- /dev/null +++ b/src/test/java/com/odeyalo/sonata/connect/controller/SeekPositionEndpointTest.java @@ -0,0 +1,186 @@ +package com.odeyalo.sonata.connect.controller; + + +import com.odeyalo.sonata.connect.dto.ConnectDeviceRequest; +import com.odeyalo.sonata.connect.dto.PlayResumePlaybackRequest; +import com.odeyalo.sonata.connect.dto.PlayerStateDto; +import com.odeyalo.sonata.connect.dto.ReasonCodeAwareExceptionMessage; +import com.odeyalo.sonata.connect.model.PlayableItemDuration; +import com.odeyalo.sonata.connect.model.TrackItem; +import com.odeyalo.sonata.connect.service.player.support.PlayableItemLoader; +import com.odeyalo.sonata.connect.service.player.support.PredefinedPlayableItemLoader; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.reactive.server.WebTestClient; +import reactor.core.publisher.Hooks; +import testing.faker.ConnectDeviceRequestFaker; +import testing.faker.PlayableItemFaker; +import testing.shared.SonataTestHttpOperations; +import testing.spring.autoconfigure.AutoConfigureSonataHttpClient; +import testing.spring.callback.ClearPlayerState; +import testing.spring.stubs.AutoConfigureSonataStubs; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; + +@SpringBootTest +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@AutoConfigureWebTestClient +@AutoConfigureSonataStubs +@AutoConfigureSonataHttpClient +@ActiveProfiles("test") +@ClearPlayerState +class SeekPositionEndpointTest { + + + @Autowired + WebTestClient webClient; + + @Autowired + SonataTestHttpOperations sonataTestHttpOperations; + + static final String VALID_ACCESS_TOKEN = "Bearer mikunakanoisthebestgirl"; + static final String PLAYABLE_ITEM_CONTEXT_URI = "sonata:track:cassie"; + + + @BeforeAll + void setup() { + Hooks.onOperatorDebug(); // DO NOT DELETE IT, VERY IMPORTANT LINE, WITHOUT IT FEIGN WITH WIREMOCK THROWS ILLEGAL STATE EXCEPTION, I DON'T FIND SOLUTION YET + } + + @TestConfiguration + static class Config { + @Bean + @Primary + public PlayableItemLoader testablePlayableItemLoader() { + final TrackItem trackItem = PlayableItemFaker.TrackItemFaker.create() + .withDuration(PlayableItemDuration.ofSeconds(180)) + .withContextUri(PLAYABLE_ITEM_CONTEXT_URI) + .get(); + + return new PredefinedPlayableItemLoader(List.of(trackItem)); + } + } + + @Test + void shouldReturn204StatusAfterCommandSuccess() { + connectDevice(); + + startPlayTrack(); + + final WebTestClient.ResponseSpec responseSpec = seekPositionRequest(100, "millis"); + + responseSpec.expectStatus().isNoContent(); + } + + @Test + void shouldUpdateCurrentPlayerState() { + connectDevice(); + + startPlayTrack(); + + final WebTestClient.ResponseSpec ignored = seekPositionRequest(10_000, "millis"); + + final PlayerStateDto currentState = sonataTestHttpOperations.getCurrentState(VALID_ACCESS_TOKEN); + + assertThat(currentState.getProgressMs()).isGreaterThanOrEqualTo(10_000); + } + + @Test + void shouldProperlyUpdatePositionIfSecondPrecisionIsUsed() { + connectDevice(); + + startPlayTrack(); + + final WebTestClient.ResponseSpec ignored = seekPositionRequest(20, "seconds"); + + final PlayerStateDto currentState = sonataTestHttpOperations.getCurrentState(VALID_ACCESS_TOKEN); + + assertThat(currentState.getProgressMs()).isGreaterThanOrEqualTo(20_000); + } + + @Test + void shouldReturnErrorIfInvalidPrecisionIsUsed() { + connectDevice(); + + startPlayTrack(); + + final WebTestClient.ResponseSpec responseSpec = seekPositionRequest(20, "invalid"); + + responseSpec.expectStatus().isBadRequest(); + + responseSpec.expectBody(ReasonCodeAwareExceptionMessage.class) + .value(message -> assertThat(message.getReasonCode()).isEqualTo("unsupported_precision")) + .value(message -> assertThat(message.getDescription()).isEqualTo("Player command error: unsupported precision used. Supported case insensitive: MILLIS, SECONDS.")); + } + + @Test + void shouldReturnErrorIfNothingIsPlaying() { + connectDevice(); + + final WebTestClient.ResponseSpec responseSpec = seekPositionRequest(10_000, "millis"); + + responseSpec.expectStatus().isBadRequest(); + + responseSpec.expectBody(ReasonCodeAwareExceptionMessage.class) + .value(message -> assertThat(message.getReasonCode()).isEqualTo("playable_item_required")) + .value(message -> assertThat(message.getDescription()).isEqualTo("Player command error: no item is playing")); + } + + @Test + void shouldReturnErrorIfPositionIsNegative() { + connectDevice(); + + startPlayTrack(); + + final WebTestClient.ResponseSpec responseSpec = seekPositionRequest(-10, "millis"); + + responseSpec.expectStatus().isBadRequest(); + + responseSpec.expectBody(ReasonCodeAwareExceptionMessage.class) + .value(message -> assertThat(message.getReasonCode()).isEqualTo("invalid_position")) + .value(message -> assertThat(message.getDescription()).isEqualTo("Player command error: position must be positive")); + } + + @Test + void shouldReturnErrorIfPositionIsGreaterThanTrackDuration() { + connectDevice(); + + startPlayTrack(); + + WebTestClient.ResponseSpec responseSpec = seekPositionRequest(Integer.MAX_VALUE, "millis"); + + responseSpec.expectBody(ReasonCodeAwareExceptionMessage.class) + .value(message -> assertThat(message.getReasonCode()).isEqualTo("seek_position_exceed")) + .value(message -> assertThat(message.getDescription()).isEqualTo("Player command error: position cannot be greater than track duration")); + } + + private WebTestClient.ResponseSpec seekPositionRequest(final int position, final String precision) { + return webClient.put().uri(b -> b.path("/player/seek") + .queryParam("position", position) + .queryParam("precision", precision) + .build()) + .header(AUTHORIZATION, VALID_ACCESS_TOKEN) + .exchange(); + } + + private void connectDevice() { + final ConnectDeviceRequest connectDeviceRequest = ConnectDeviceRequestFaker.create().get(); + + sonataTestHttpOperations.connectDevice(VALID_ACCESS_TOKEN, connectDeviceRequest); + } + + private void startPlayTrack() { + sonataTestHttpOperations.playOrResumePlayback(VALID_ACCESS_TOKEN, PlayResumePlaybackRequest.of(PLAYABLE_ITEM_CONTEXT_URI)); + } +} diff --git a/src/test/java/com/odeyalo/sonata/connect/model/SeekToPositionTest.java b/src/test/java/com/odeyalo/sonata/connect/model/SeekToPositionTest.java new file mode 100644 index 0000000..0795eb2 --- /dev/null +++ b/src/test/java/com/odeyalo/sonata/connect/model/SeekToPositionTest.java @@ -0,0 +1,56 @@ +package com.odeyalo.sonata.connect.model; + +import com.odeyalo.sonata.connect.exception.MissingPlayableItemException; +import com.odeyalo.sonata.connect.exception.SeekPositionExceedDurationException; +import com.odeyalo.sonata.connect.service.player.SeekPosition; +import org.junit.jupiter.api.Test; +import testing.faker.PlayableItemFaker; +import testing.time.TestingClock; + +import java.time.Duration; +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public final class SeekToPositionTest { + + public static final PlayableItem SIMPLE_TRACK = PlayableItemFaker.create() + .setDuration(Duration.ofSeconds(200)) + .get(); + public static final User USER = User.of("123"); + + @Test + void shouldProperlySeekPlayerProgressToPosition() { + final TestingClock timer = new TestingClock(Instant.now()); + + final CurrentPlayerState initialPlayer = CurrentPlayerState.emptyFor(User.of("123")) + .useClock(timer); + + final CurrentPlayerState afterPlay = initialPlayer.play(SIMPLE_TRACK); + + timer.waitSeconds(5); + + final CurrentPlayerState afterSeek = afterPlay.seekTo(SeekPosition.ofMillis(1000)); + + assertThat(afterSeek.getProgressMs()).isEqualTo(1000); + } + + @Test + void shouldThrowExceptionIfSeekPositionIsGreaterThanTrackDuration() { + final CurrentPlayerState initialPlayer = CurrentPlayerState.emptyFor(USER); + + final CurrentPlayerState afterPlay = initialPlayer.play(SIMPLE_TRACK); + + assertThatThrownBy(() -> afterPlay.seekTo(SeekPosition.ofMillis(Integer.MAX_VALUE))) + .isInstanceOf(SeekPositionExceedDurationException.class); + } + + @Test + void shouldThrowExceptionIfThereIsNoPlayableItem() { + final CurrentPlayerState initialPlayer = CurrentPlayerState.emptyFor(USER); + + assertThatThrownBy(() -> initialPlayer.seekTo(SeekPosition.ofMillis(1000))) + .isInstanceOf(MissingPlayableItemException.class); + } +} diff --git a/src/test/java/com/odeyalo/sonata/connect/service/player/EventPublisherPlayerOperationsDecoratorTest.java b/src/test/java/com/odeyalo/sonata/connect/service/player/EventPublisherPlayerOperationsDecoratorTest.java index b1888b7..0043398 100644 --- a/src/test/java/com/odeyalo/sonata/connect/service/player/EventPublisherPlayerOperationsDecoratorTest.java +++ b/src/test/java/com/odeyalo/sonata/connect/service/player/EventPublisherPlayerOperationsDecoratorTest.java @@ -5,6 +5,7 @@ import com.odeyalo.sonata.connect.entity.DevicesEntity; import com.odeyalo.sonata.connect.entity.PlayerStateEntity; import com.odeyalo.sonata.connect.exception.NoActiveDeviceException; +import com.odeyalo.sonata.connect.exception.PlayerCommandException; import com.odeyalo.sonata.connect.model.*; import com.odeyalo.sonata.connect.service.player.sync.DefaultPlayerSynchronizationManager; import com.odeyalo.sonata.connect.service.player.sync.InMemoryRoomHolder; @@ -21,7 +22,7 @@ import testing.faker.DeviceEntityFaker; import testing.faker.PlayableItemFaker.TrackItemFaker; import testing.faker.PlayerStateFaker; -import testing.stub.NullDeviceOperations; +import testing.faker.TrackItemEntityFaker; import java.util.ArrayList; import java.util.Collections; @@ -259,7 +260,7 @@ void shouldInvokeDelegatePause() { } @Nested - class ChangeVolumeCommandTest { + class ChangeVolumeCommandTest { @Test void shouldSendEventOnVolumeChange() { @@ -345,11 +346,77 @@ void shouldInvokeDelegateChangeVolumeMethod() { } } + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class SeekPositionTest { + + @Test + void shouldPublishEventWithSeekPosition() { + PlayerStateEntity playerState = PlayerStateFaker + .forUser(USER) + .currentlyPlayingItem(TrackItemEntityFaker.create().withDuration(PlayableItemDuration.ofSeconds(180)).get()) + .get(); + + EventCollectorPlayerSynchronizationManager synchronizationManagerMock = new EventCollectorPlayerSynchronizationManager(); + + EventPublisherPlayerOperationsDecorator testable = testableBuilder() + .withPlayerState(playerState) + .withSynchronizationManager(synchronizationManagerMock) + .build(); + + testable.seekToPosition(USER, SeekPosition.ofMillis(25_000)).block(); + + assertThat(synchronizationManagerMock.getOccurredEvents()).hasSize(1) + .first().matches(event -> event.getCurrentPlayerState().getProgressMs() >= 25_000); + } + + @Test + void shouldInvokeDelegateSeekToMethod() { + CurrentPlayerState playerState = CurrentPlayerStateFaker.create().withUser(USER).get(); + SeekPosition seekPosition = SeekPosition.ofMillis(25_000); + + BasicPlayerOperations delegateMock = mock(BasicPlayerOperations.class); + + when(delegateMock.seekToPosition(USER, seekPosition)).thenReturn(Mono.just(playerState)); + + EventPublisherPlayerOperationsDecorator testable = testableBuilder() + .withDelegate(delegateMock) + .build(); + + testable.seekToPosition(USER, seekPosition).block(); + + verify(delegateMock, times(1)).seekToPosition(eq(USER), eq(seekPosition)); + } + + @Test + void shouldNotSendEventIfErrorOccurred() { + // given + PlayerStateEntity playerState = PlayerStateFaker + .forUser(USER) + .currentlyPlayingItem(null) + .get(); + + EventCollectorPlayerSynchronizationManager synchronizationManagerMock = new EventCollectorPlayerSynchronizationManager(); + + EventPublisherPlayerOperationsDecorator testable = testableBuilder() + .withPlayerState(playerState) + .withSynchronizationManager(synchronizationManagerMock) + .build(); + + // when, then + testable.seekToPosition(USER, SeekPosition.ofSeconds(10)) + .as(StepVerifier::create) + .expectError(PlayerCommandException.class) + .verify(); + + assertThat(synchronizationManagerMock.getOccurredEvents()).isEmpty(); + } + } + static class TestableBuilder { private final DefaultPlayerOperationsTestableBuilder delegateBuilder = DefaultPlayerOperationsTestableBuilder.testableBuilder(); private BasicPlayerOperations delegate; private PlayerSynchronizationManager synchronizationManager = new DefaultPlayerSynchronizationManager(new InMemoryRoomHolder()); - private final DeviceOperations deviceOperations = new NullDeviceOperations(); public static TestableBuilder testableBuilder() { return new TestableBuilder(); @@ -367,7 +434,7 @@ public TestableBuilder withSynchronizationManager(PlayerSynchronizationManager s public EventPublisherPlayerOperationsDecorator build() { delegate = delegate == null ? delegateBuilder.build() : delegate; - return new EventPublisherPlayerOperationsDecorator(delegate, synchronizationManager, deviceOperations); + return new EventPublisherPlayerOperationsDecorator(delegate, synchronizationManager); } public TestableBuilder withPlayerState(PlayerStateEntity playerState) { diff --git a/src/test/java/com/odeyalo/sonata/connect/service/player/SeekToPositionOperationTest.java b/src/test/java/com/odeyalo/sonata/connect/service/player/SeekToPositionOperationTest.java new file mode 100644 index 0000000..f3bd628 --- /dev/null +++ b/src/test/java/com/odeyalo/sonata/connect/service/player/SeekToPositionOperationTest.java @@ -0,0 +1,78 @@ +package com.odeyalo.sonata.connect.service.player; + +import com.odeyalo.sonata.common.context.ContextUri; +import com.odeyalo.sonata.connect.entity.PlayerStateEntity; +import com.odeyalo.sonata.connect.exception.MissingPlayableItemException; +import com.odeyalo.sonata.connect.exception.SeekPositionExceedDurationException; +import com.odeyalo.sonata.connect.model.PlayableItem; +import com.odeyalo.sonata.connect.model.PlayableItemDuration; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; +import testing.faker.DevicesEntityFaker; +import testing.faker.PlayableItemFaker; + +import static com.odeyalo.sonata.connect.service.player.BasicPlayerOperations.CURRENT_DEVICE; +import static org.assertj.core.api.Assertions.assertThat; +import static testing.factory.DefaultPlayerOperationsTestableBuilder.testableBuilder; + +class SeekToPositionOperationTest extends DefaultPlayerOperationsTest { + + public static final PlayableItem TRACK = PlayableItemFaker.TrackItemFaker.create().withContextUri("sonata:track:miku") + .withDuration(PlayableItemDuration.ofSeconds(180)) + .get(); + + @Test + void shouldSeekToPosition() { + PlayerStateEntity playerState = existingPlayerState() + .setDevicesEntity(DevicesEntityFaker.create().get()); + + DefaultPlayerOperations testable = testableBuilder() + .withState(playerState) + .withPlayableItems(TRACK) + .build(); + + testable.playOrResume(EXISTING_USER, PlayCommandContext.from(ContextUri.forTrack("miku")), CURRENT_DEVICE).block(); + + testable.seekToPosition(EXISTING_USER, SeekPosition.ofSeconds(20)) + .as(StepVerifier::create) + // don't want to state.useClock(MockClock) to not break the encapsulation so using isGreaterThanOrEqualTo + .assertNext(state -> assertThat(state.getProgressMs()).isGreaterThanOrEqualTo(20_000)) + .verifyComplete(); + } + + @Test + void shouldReturnErrorIfPositionExceedTheItemDuration() { + PlayerStateEntity playerState = existingPlayerState() + .setDevicesEntity(DevicesEntityFaker.create().get()); + + DefaultPlayerOperations testable = testableBuilder() + .withState(playerState) + .withPlayableItems(TRACK) + .build(); + + testable.playOrResume(EXISTING_USER, PlayCommandContext.from(ContextUri.forTrack("miku")), CURRENT_DEVICE).block(); + + testable.seekToPosition(EXISTING_USER, SeekPosition.ofSeconds(10_000)) + .as(StepVerifier::create) + .expectError(SeekPositionExceedDurationException.class) + .verify(); + } + + @Test + void shouldReturnErrorIfNothingIsPlaying() { + PlayerStateEntity playerState = existingPlayerState() + .setDevicesEntity(DevicesEntityFaker.create().get()) + .setCurrentlyPlayingItem(null) + .setPlayingType(null); + + DefaultPlayerOperations testable = testableBuilder() + .withState(playerState) + .withPlayableItems(TRACK) + .build(); + + testable.seekToPosition(EXISTING_USER, SeekPosition.ofSeconds(20)) + .as(StepVerifier::create) + .expectError(MissingPlayableItemException.class) + .verify(); + } +} diff --git a/src/test/java/testing/factory/DefaultPlayerOperationsTestableBuilder.java b/src/test/java/testing/factory/DefaultPlayerOperationsTestableBuilder.java index 0fa8d2c..064e190 100644 --- a/src/test/java/testing/factory/DefaultPlayerOperationsTestableBuilder.java +++ b/src/test/java/testing/factory/DefaultPlayerOperationsTestableBuilder.java @@ -9,7 +9,6 @@ import com.odeyalo.sonata.connect.repository.InMemoryPlayerStateRepository; import com.odeyalo.sonata.connect.repository.PlayerStateRepository; import com.odeyalo.sonata.connect.service.player.DefaultPlayerOperations; -import com.odeyalo.sonata.connect.service.player.DeviceOperations; import com.odeyalo.sonata.connect.service.player.PlayerStateService; import com.odeyalo.sonata.connect.service.player.handler.PauseCommandHandlerDelegate; import com.odeyalo.sonata.connect.service.player.handler.PlayCommandHandlerDelegate; @@ -22,14 +21,12 @@ import com.odeyalo.sonata.connect.service.support.mapper.CurrentPlayerState2CurrentlyPlayingPlayerStateConverter; import com.odeyalo.sonata.connect.service.support.mapper.PlayerState2CurrentPlayerStateConverter; import org.jetbrains.annotations.NotNull; -import testing.stub.NullDeviceOperations; import java.util.ArrayList; import java.util.List; public final class DefaultPlayerOperationsTestableBuilder { private final PlayerStateRepository playerStateRepository = new InMemoryPlayerStateRepository(); - private final DeviceOperations deviceOperations = new NullDeviceOperations(); private final PlayerState2CurrentPlayerStateConverter playerStateConverterSupport = new Converters().playerState2CurrentPlayerStateConverter(); private final CurrentPlayerState2CurrentlyPlayingPlayerStateConverter playerStateConverter = new Converters().currentPlayerStateConverter(); @@ -59,7 +56,6 @@ public DefaultPlayerOperationsTestableBuilder withPlayableItems(PlayableItem... public DefaultPlayerOperations build() { return new DefaultPlayerOperations( - deviceOperations, PlayCommandHandlerBuilder.builder() .withState(playerStateRepository) .withPlayableItems(existingItems) diff --git a/src/test/java/testing/faker/PlayableItemFaker.java b/src/test/java/testing/faker/PlayableItemFaker.java index dae21b2..29e90b9 100644 --- a/src/test/java/testing/faker/PlayableItemFaker.java +++ b/src/test/java/testing/faker/PlayableItemFaker.java @@ -83,7 +83,7 @@ public TrackItemFaker withId(final String id) { return this; } - public PlayableItemFaker withDuration(final PlayableItemDuration duration) { + public TrackItemFaker withDuration(final PlayableItemDuration duration) { this.duration = duration; builder.duration(duration); return this; diff --git a/src/test/java/testing/faker/TrackItemEntityFaker.java b/src/test/java/testing/faker/TrackItemEntityFaker.java index efffc90..911e15f 100644 --- a/src/test/java/testing/faker/TrackItemEntityFaker.java +++ b/src/test/java/testing/faker/TrackItemEntityFaker.java @@ -45,4 +45,9 @@ public TrackItemEntityFaker withId(final String id) { .contextUri(ContextUri.forTrack(id)); return this; } + + public TrackItemEntityFaker withDuration(final PlayableItemDuration duration) { + builder.duration(duration); + return this; + } }