From 2b642cb20cc6a30ceed2f3acb317576f519f41d1 Mon Sep 17 00:00:00 2001 From: odeyalo Date: Fri, 17 Oct 2025 16:28:29 +0300 Subject: [PATCH 01/21] Add basic PlaylistGenerationService --- build.gradle | 5 +- .../kafka/KafkaConsumerConfiguration.java | 55 ++++++++++++++++++ .../generation/PlaylistGenerationService.java | 57 +++++++++++++++++++ .../generation/consumer/MessageConsumer.java | 37 ++++++++++++ .../PlaylistGenerationServiceTest.java | 43 ++++++++++++++ 5 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/odeyalo/sonata/playlists/config/kafka/KafkaConsumerConfiguration.java create mode 100644 src/main/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationService.java create mode 100644 src/main/java/com/odeyalo/sonata/playlists/service/generation/consumer/MessageConsumer.java create mode 100644 src/test/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationServiceTest.java diff --git a/build.gradle b/build.gradle index f74b83d..0c036a2 100644 --- a/build.gradle +++ b/build.gradle @@ -45,9 +45,12 @@ dependencies { implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client' implementation 'org.jetbrains:annotations:23.0.0' implementation 'org.flywaydb:flyway-core' - implementation 'com.odeyalo.sonata:suite:1.0.0' + implementation 'com.odeyalo.sonata.suite:common:1.0.1`' + implementation 'com.odeyalo.sonata.suite:suite-brokers:0.0.11' implementation "com.odeyalo.sonata.suite:security:${suiteSecurityVersion}" implementation "org.mapstruct:mapstruct:${mapstructVersion}" + implementation 'io.projectreactor.kafka:reactor-kafka:1.3.21' + implementation 'org.springframework.kafka:spring-kafka:3.0.9' compileOnly 'org.projectlombok:lombok' runtimeOnly 'org.postgresql:postgresql' runtimeOnly 'org.postgresql:r2dbc-postgresql' diff --git a/src/main/java/com/odeyalo/sonata/playlists/config/kafka/KafkaConsumerConfiguration.java b/src/main/java/com/odeyalo/sonata/playlists/config/kafka/KafkaConsumerConfiguration.java new file mode 100644 index 0000000..6fb7c9c --- /dev/null +++ b/src/main/java/com/odeyalo/sonata/playlists/config/kafka/KafkaConsumerConfiguration.java @@ -0,0 +1,55 @@ +package com.odeyalo.sonata.playlists.config.kafka; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.fasterxml.jackson.module.paramnames.ParameterNamesModule; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.PlaylistImagesGeneratedEvent; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.payload.GenerativePlaylistEvent; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.core.reactive.ReactiveKafkaConsumerTemplate; +import org.springframework.kafka.support.serializer.JsonDeserializer; +import reactor.kafka.receiver.ReceiverOptions; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +@Configuration +public class KafkaConsumerConfiguration { + + @Bean + public Jackson2ObjectMapperBuilderCustomizer customizer() { + return jacksonObjectMapperBuilder -> jacksonObjectMapperBuilder.modules( + new JavaTimeModule(), + new ParameterNamesModule() + ); + } + + @Bean + public ReceiverOptions generatedPlaylistReceiverOptions(ObjectMapper objectMapper) { + final Map consumerProps = new HashMap<>(); + consumerProps.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092"); + consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, "generated-playlists-consumers"); + consumerProps.put(JsonDeserializer.TRUSTED_PACKAGES, "*"); + consumerProps.put(JsonDeserializer.USE_TYPE_INFO_HEADERS, false); + consumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, JsonDeserializer.class); + + final JsonDeserializer deserializer = new JsonDeserializer<>(GenerativePlaylistEvent.class, objectMapper); + + deserializer.setUseTypeHeaders(false); + + return ReceiverOptions.create(consumerProps) + .withValueDeserializer(deserializer) + .subscription(Collections.singleton("playlists.gen.images")); + } + + @Bean + public ReactiveKafkaConsumerTemplate reactiveKafkaConsumerTemplate(ReceiverOptions kafkaReceiverOptions) { + return new ReactiveKafkaConsumerTemplate<>(kafkaReceiverOptions); + } +} diff --git a/src/main/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationService.java b/src/main/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationService.java new file mode 100644 index 0000000..c8d632c --- /dev/null +++ b/src/main/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationService.java @@ -0,0 +1,57 @@ +package com.odeyalo.sonata.playlists.service.generation; + +import com.odeyalo.sonata.playlists.model.*; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.PlaylistImagesGeneratedEvent; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.payload.PlaylistImagesGeneratedPayload; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.payload.PlaylistMetaGeneratedPayload; +import org.jetbrains.annotations.NotNull; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +import java.util.List; + +@Service +public final class PlaylistGenerationService { + + private static final PlaylistOwner SONATA_ACCOUNT = PlaylistOwner.builder() + .id("sonata") + .displayName("Sonata") + .entityType(EntityType.USER) + .build(); + + @NotNull + public Mono generate(@NotNull final PlaylistImagesGeneratedEvent event) { + + final Playlist.PlaylistBuilder playlistBuilder = Playlist.builder(); + + final PlaylistImagesGeneratedPayload body = event.getBody(); + + final Playlist.PlaylistBuilder baseInfoPlaylistBuilder = baseInfoPlaylist(body, playlistBuilder); + + Playlist playlist = playlistBuilder.build(); + + + return Mono.just(playlist); + } + + @NotNull + private static Playlist.PlaylistBuilder baseInfoPlaylist(final PlaylistImagesGeneratedPayload body, final Playlist.PlaylistBuilder playlistBuilder) { + PlaylistMetaGeneratedPayload meta = body.getParent(); + + final PlaylistId playlistId = PlaylistId.random(); + + final List images = body.getImages() + .stream() + .map(image -> Image.of(image.getUrl(), image.getWidth(), image.getHeight())) + .toList(); + + return playlistBuilder + .id(playlistId) + .name(meta.getMeta().getName()) + .description(meta.getMeta().getDescription()) + .contextUri(playlistId.asContextUri()) + .playlistType(PlaylistType.PUBLIC) + .images(Images.of(images)) + .playlistOwner(SONATA_ACCOUNT); + } +} diff --git a/src/main/java/com/odeyalo/sonata/playlists/service/generation/consumer/MessageConsumer.java b/src/main/java/com/odeyalo/sonata/playlists/service/generation/consumer/MessageConsumer.java new file mode 100644 index 0000000..8b2a2c9 --- /dev/null +++ b/src/main/java/com/odeyalo/sonata/playlists/service/generation/consumer/MessageConsumer.java @@ -0,0 +1,37 @@ +package com.odeyalo.sonata.playlists.service.generation.consumer; + +import com.odeyalo.sonata.playlists.repository.PlaylistRepository; +import com.odeyalo.sonata.playlists.service.PlaylistService; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.PlaylistImagesGeneratedEvent; +import jakarta.annotation.PostConstruct; +import lombok.extern.log4j.Log4j2; +import org.springframework.kafka.core.reactive.ReactiveKafkaConsumerTemplate; +import org.springframework.stereotype.Component; +import reactor.core.Disposable; +import reactor.core.scheduler.Schedulers; +import reactor.kafka.receiver.ReceiverRecord; + +@Component +@Log4j2 +public final class MessageConsumer { + private final ReactiveKafkaConsumerTemplate reactiveKafkaConsumerTemplate; + private final PlaylistService playlistService; + private PlaylistRepository playlistRepository; + + // TODO: save the received playlist to database + public MessageConsumer(final ReactiveKafkaConsumerTemplate reactiveKafkaConsumerTemplate, + final PlaylistService playlistService) { + this.reactiveKafkaConsumerTemplate = reactiveKafkaConsumerTemplate; + this.playlistService = playlistService; + } + + @PostConstruct + public Disposable consumeRecord() { + return reactiveKafkaConsumerTemplate.receive() + .map(ReceiverRecord::value) + .doOnNext(msg -> log.info("Received: {}", msg)) + .doOnError(error -> log.error("Consumer error: {}", error.getMessage())) + .subscribeOn(Schedulers.boundedElastic()) + .subscribe(); + } +} diff --git a/src/test/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationServiceTest.java b/src/test/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationServiceTest.java new file mode 100644 index 0000000..03fd6da --- /dev/null +++ b/src/test/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationServiceTest.java @@ -0,0 +1,43 @@ +package com.odeyalo.sonata.playlists.service.generation; + +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.GeneratedPlaylistType; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.PlaylistImagesGeneratedEvent; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.payload.GeneratedTrack; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.payload.PlaylistImagesGeneratedPayload; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.payload.PlaylistMetaGeneratedPayload; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.payload.PlaylistTracksGeneratedPayload; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class PlaylistGenerationServiceTest { + + @Test + void shouldSetThePlaylistMetadata() { + // given + final PlaylistImagesGeneratedEvent event = new PlaylistImagesGeneratedEvent( + new PlaylistImagesGeneratedPayload(new PlaylistMetaGeneratedPayload( + new PlaylistTracksGeneratedPayload("123", List.of( + new GeneratedTrack("1", 0), + new GeneratedTrack("2", 1), + new GeneratedTrack("3", 2) + )), + new PlaylistMetaGeneratedPayload.Meta("On Repeat", "Songs you love the most") + ), List.of(new PlaylistImagesGeneratedPayload.Image("https://cdn.sonata.com/i/c/abc123"))), + GeneratedPlaylistType.ON_REPEAT); + + final PlaylistGenerationService testable = new PlaylistGenerationService(); + + // when + testable.generate(event) + .as(StepVerifier::create) + .assertNext(playlist -> { + assertThat(playlist.getName()).isEqualTo("On Repeat"); + assertThat(playlist.getDescription()).isEqualTo("Songs you love the most"); + }) + .verifyComplete(); + } +} \ No newline at end of file From efe957363fc8ff67f65c06bc709789c2e61259e8 Mon Sep 17 00:00:00 2001 From: odeyalo Date: Fri, 17 Oct 2025 16:33:47 +0300 Subject: [PATCH 02/21] Generate proper images --- .../PlaylistGenerationServiceTest.java | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/test/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationServiceTest.java b/src/test/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationServiceTest.java index 03fd6da..2b3e19d 100644 --- a/src/test/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationServiceTest.java +++ b/src/test/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationServiceTest.java @@ -1,5 +1,6 @@ package com.odeyalo.sonata.playlists.service.generation; +import com.odeyalo.sonata.playlists.model.Image; import com.odeyalo.sonata.suite.brokers.events.playlist.gen.GeneratedPlaylistType; import com.odeyalo.sonata.suite.brokers.events.playlist.gen.PlaylistImagesGeneratedEvent; import com.odeyalo.sonata.suite.brokers.events.playlist.gen.payload.GeneratedTrack; @@ -40,4 +41,36 @@ void shouldSetThePlaylistMetadata() { }) .verifyComplete(); } + + @Test + void shouldSetThePlaylistImages() { + // given + final PlaylistImagesGeneratedEvent event = new PlaylistImagesGeneratedEvent( + new PlaylistImagesGeneratedPayload(new PlaylistMetaGeneratedPayload( + new PlaylistTracksGeneratedPayload("123", List.of( + new GeneratedTrack("1", 0), + new GeneratedTrack("2", 1), + new GeneratedTrack("3", 2) + )), + new PlaylistMetaGeneratedPayload.Meta("On Repeat", "Songs you love the most") + ), List.of( + new PlaylistImagesGeneratedPayload.Image("https://cdn.sonata.com/i/c/abc123", 50, 50), + new PlaylistImagesGeneratedPayload.Image("https://cdn.sonata.com/i/c/abc124", 300, 350), + new PlaylistImagesGeneratedPayload.Image("https://cdn.sonata.com/i/c/abc125", 600, 600) + )), GeneratedPlaylistType.ON_REPEAT); + + final PlaylistGenerationService testable = new PlaylistGenerationService(); + + // when + testable.generate(event) + .as(StepVerifier::create) + .assertNext(playlist -> { + assertThat(playlist.getImages()).containsExactlyInAnyOrder( + Image.of("https://cdn.sonata.com/i/c/abc123", 50, 50), + Image.of("https://cdn.sonata.com/i/c/abc124", 350, 300), + Image.of("https://cdn.sonata.com/i/c/abc125", 600, 600) + ); + }) + .verifyComplete(); + } } \ No newline at end of file From bbe3e0504e74a7b551418566856173162c03b809 Mon Sep 17 00:00:00 2001 From: odeyalo Date: Fri, 17 Oct 2025 16:40:14 +0300 Subject: [PATCH 03/21] Use default account --- .../PlaylistGenerationServiceTest.java | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/test/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationServiceTest.java b/src/test/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationServiceTest.java index 2b3e19d..e985815 100644 --- a/src/test/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationServiceTest.java +++ b/src/test/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationServiceTest.java @@ -1,6 +1,8 @@ package com.odeyalo.sonata.playlists.service.generation; +import com.odeyalo.sonata.playlists.model.EntityType; import com.odeyalo.sonata.playlists.model.Image; +import com.odeyalo.sonata.playlists.model.PlaylistType; import com.odeyalo.sonata.suite.brokers.events.playlist.gen.GeneratedPlaylistType; import com.odeyalo.sonata.suite.brokers.events.playlist.gen.PlaylistImagesGeneratedEvent; import com.odeyalo.sonata.suite.brokers.events.playlist.gen.payload.GeneratedTrack; @@ -38,6 +40,8 @@ void shouldSetThePlaylistMetadata() { .assertNext(playlist -> { assertThat(playlist.getName()).isEqualTo("On Repeat"); assertThat(playlist.getDescription()).isEqualTo("Songs you love the most"); + assertThat(playlist.getPlaylistType()).isEqualTo(PlaylistType.PUBLIC); + assertThat(playlist.getContextUri().asString()).isEqualTo("sonata:playlist:" + playlist.getId().value()); }) .verifyComplete(); } @@ -73,4 +77,32 @@ void shouldSetThePlaylistImages() { }) .verifyComplete(); } + + @Test + void shouldSetThePlaylistOwner() { + // given + final PlaylistImagesGeneratedEvent event = new PlaylistImagesGeneratedEvent( + new PlaylistImagesGeneratedPayload(new PlaylistMetaGeneratedPayload( + new PlaylistTracksGeneratedPayload("123", List.of( + new GeneratedTrack("1", 0), + new GeneratedTrack("2", 1), + new GeneratedTrack("3", 2) + )), + new PlaylistMetaGeneratedPayload.Meta("On Repeat", "Songs you love the most") + ), List.of( + new PlaylistImagesGeneratedPayload.Image("https://cdn.sonata.com/i/c/abc123", 50, 50) + )), GeneratedPlaylistType.ON_REPEAT); + + final PlaylistGenerationService testable = new PlaylistGenerationService(); + + // when + testable.generate(event) + .as(StepVerifier::create) + .assertNext(playlist -> { + assertThat(playlist.getPlaylistOwner().getId()).isEqualTo("sonata"); + assertThat(playlist.getPlaylistOwner().getDisplayName()).isEqualTo("Sonata"); + assertThat(playlist.getPlaylistOwner().getEntityType()).isEqualTo(EntityType.USER); + }) + .verifyComplete(); + } } \ No newline at end of file From 34f32f1e61609d447fbaf697aa69f56a33b1443e Mon Sep 17 00:00:00 2001 From: odeyalo Date: Fri, 17 Oct 2025 16:51:36 +0300 Subject: [PATCH 04/21] PlaylistGenerationService produce GeneratedPlaylist --- .../service/generation/GeneratedPlaylist.java | 17 +++++++ .../generation/PlaylistGenerationService.java | 7 ++- .../PlaylistGenerationServiceTest.java | 51 +++++++++---------- 3 files changed, 46 insertions(+), 29 deletions(-) create mode 100644 src/main/java/com/odeyalo/sonata/playlists/service/generation/GeneratedPlaylist.java diff --git a/src/main/java/com/odeyalo/sonata/playlists/service/generation/GeneratedPlaylist.java b/src/main/java/com/odeyalo/sonata/playlists/service/generation/GeneratedPlaylist.java new file mode 100644 index 0000000..4ee8295 --- /dev/null +++ b/src/main/java/com/odeyalo/sonata/playlists/service/generation/GeneratedPlaylist.java @@ -0,0 +1,17 @@ +package com.odeyalo.sonata.playlists.service.generation; + +import com.odeyalo.sonata.playlists.model.Playlist; +import com.odeyalo.sonata.playlists.model.PlaylistItem; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +/** + * A meta that was auto-generated by Sonata + * @param meta - generated meta info + * @param tracks - tracks in this meta + */ +public record GeneratedPlaylist( + @NotNull Playlist meta, + @NotNull List tracks +) { } diff --git a/src/main/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationService.java b/src/main/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationService.java index c8d632c..b028af7 100644 --- a/src/main/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationService.java +++ b/src/main/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationService.java @@ -8,6 +8,7 @@ import org.springframework.stereotype.Service; import reactor.core.publisher.Mono; +import java.util.Collections; import java.util.List; @Service @@ -20,7 +21,7 @@ public final class PlaylistGenerationService { .build(); @NotNull - public Mono generate(@NotNull final PlaylistImagesGeneratedEvent event) { + public Mono generate(@NotNull final PlaylistImagesGeneratedEvent event) { final Playlist.PlaylistBuilder playlistBuilder = Playlist.builder(); @@ -31,7 +32,9 @@ public Mono generate(@NotNull final PlaylistImagesGeneratedEvent event Playlist playlist = playlistBuilder.build(); - return Mono.just(playlist); + return Mono.just( + new GeneratedPlaylist(playlist, Collections.emptyList()) + ); } @NotNull diff --git a/src/test/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationServiceTest.java b/src/test/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationServiceTest.java index e985815..c7d0440 100644 --- a/src/test/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationServiceTest.java +++ b/src/test/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationServiceTest.java @@ -2,6 +2,7 @@ import com.odeyalo.sonata.playlists.model.EntityType; import com.odeyalo.sonata.playlists.model.Image; +import com.odeyalo.sonata.playlists.model.Playlist; import com.odeyalo.sonata.playlists.model.PlaylistType; import com.odeyalo.sonata.suite.brokers.events.playlist.gen.GeneratedPlaylistType; import com.odeyalo.sonata.suite.brokers.events.playlist.gen.PlaylistImagesGeneratedEvent; @@ -35,15 +36,15 @@ void shouldSetThePlaylistMetadata() { final PlaylistGenerationService testable = new PlaylistGenerationService(); // when - testable.generate(event) - .as(StepVerifier::create) - .assertNext(playlist -> { - assertThat(playlist.getName()).isEqualTo("On Repeat"); - assertThat(playlist.getDescription()).isEqualTo("Songs you love the most"); - assertThat(playlist.getPlaylistType()).isEqualTo(PlaylistType.PUBLIC); - assertThat(playlist.getContextUri().asString()).isEqualTo("sonata:playlist:" + playlist.getId().value()); - }) - .verifyComplete(); + GeneratedPlaylist generatedPlaylist = testable.generate(event).block(); + assertThat(generatedPlaylist).isNotNull(); + + final Playlist playlistInfo = generatedPlaylist.meta(); + + assertThat(playlistInfo.getName()).isEqualTo("On Repeat"); + assertThat(playlistInfo.getDescription()).isEqualTo("Songs you love the most"); + assertThat(playlistInfo.getPlaylistType()).isEqualTo(PlaylistType.PUBLIC); + assertThat(playlistInfo.getContextUri().asString()).isEqualTo("sonata:playlist:" + playlistInfo.getId().value()); } @Test @@ -66,16 +67,14 @@ void shouldSetThePlaylistImages() { final PlaylistGenerationService testable = new PlaylistGenerationService(); // when - testable.generate(event) - .as(StepVerifier::create) - .assertNext(playlist -> { - assertThat(playlist.getImages()).containsExactlyInAnyOrder( - Image.of("https://cdn.sonata.com/i/c/abc123", 50, 50), - Image.of("https://cdn.sonata.com/i/c/abc124", 350, 300), - Image.of("https://cdn.sonata.com/i/c/abc125", 600, 600) - ); - }) - .verifyComplete(); + GeneratedPlaylist generatedPlaylist = testable.generate(event).block(); + assertThat(generatedPlaylist).isNotNull(); + + assertThat(generatedPlaylist.meta().getImages()).containsExactlyInAnyOrder( + Image.of("https://cdn.sonata.com/i/c/abc123", 50, 50), + Image.of("https://cdn.sonata.com/i/c/abc124", 350, 300), + Image.of("https://cdn.sonata.com/i/c/abc125", 600, 600) + ); } @Test @@ -96,13 +95,11 @@ void shouldSetThePlaylistOwner() { final PlaylistGenerationService testable = new PlaylistGenerationService(); // when - testable.generate(event) - .as(StepVerifier::create) - .assertNext(playlist -> { - assertThat(playlist.getPlaylistOwner().getId()).isEqualTo("sonata"); - assertThat(playlist.getPlaylistOwner().getDisplayName()).isEqualTo("Sonata"); - assertThat(playlist.getPlaylistOwner().getEntityType()).isEqualTo(EntityType.USER); - }) - .verifyComplete(); + GeneratedPlaylist generatedPlaylist = testable.generate(event).block(); + assertThat(generatedPlaylist).isNotNull(); + + assertThat(generatedPlaylist.meta().getPlaylistOwner().getId()).isEqualTo("sonata"); + assertThat(generatedPlaylist.meta().getPlaylistOwner().getDisplayName()).isEqualTo("Sonata"); + assertThat(generatedPlaylist.meta().getPlaylistOwner().getEntityType()).isEqualTo(EntityType.USER); } } \ No newline at end of file From acbcf76b8e509b2d83a875fce7be0ed56c42e4fc Mon Sep 17 00:00:00 2001 From: odeyalo Date: Fri, 17 Oct 2025 17:15:41 +0300 Subject: [PATCH 05/21] Add playlist items --- .../service/generation/GeneratedPlaylist.java | 12 ++++- .../generation/PlaylistGenerationService.java | 31 ++++++++----- .../PlaylistGenerationServiceTest.java | 45 ++++++++++++++++--- 3 files changed, 67 insertions(+), 21 deletions(-) diff --git a/src/main/java/com/odeyalo/sonata/playlists/service/generation/GeneratedPlaylist.java b/src/main/java/com/odeyalo/sonata/playlists/service/generation/GeneratedPlaylist.java index 4ee8295..39ce7f0 100644 --- a/src/main/java/com/odeyalo/sonata/playlists/service/generation/GeneratedPlaylist.java +++ b/src/main/java/com/odeyalo/sonata/playlists/service/generation/GeneratedPlaylist.java @@ -1,5 +1,6 @@ package com.odeyalo.sonata.playlists.service.generation; +import com.odeyalo.sonata.playlists.model.PlayableItemType; import com.odeyalo.sonata.playlists.model.Playlist; import com.odeyalo.sonata.playlists.model.PlaylistItem; import org.jetbrains.annotations.NotNull; @@ -13,5 +14,12 @@ */ public record GeneratedPlaylist( @NotNull Playlist meta, - @NotNull List tracks -) { } + @NotNull List tracks +) { + + public record Item( + @NotNull String id, + @NotNull PlayableItemType type, + int index + ) {} +} diff --git a/src/main/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationService.java b/src/main/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationService.java index b028af7..3ed1ca2 100644 --- a/src/main/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationService.java +++ b/src/main/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationService.java @@ -2,13 +2,13 @@ import com.odeyalo.sonata.playlists.model.*; import com.odeyalo.sonata.suite.brokers.events.playlist.gen.PlaylistImagesGeneratedEvent; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.payload.GeneratedTrack; import com.odeyalo.sonata.suite.brokers.events.playlist.gen.payload.PlaylistImagesGeneratedPayload; import com.odeyalo.sonata.suite.brokers.events.playlist.gen.payload.PlaylistMetaGeneratedPayload; import org.jetbrains.annotations.NotNull; import org.springframework.stereotype.Service; import reactor.core.publisher.Mono; -import java.util.Collections; import java.util.List; @Service @@ -23,23 +23,20 @@ public final class PlaylistGenerationService { @NotNull public Mono generate(@NotNull final PlaylistImagesGeneratedEvent event) { - final Playlist.PlaylistBuilder playlistBuilder = Playlist.builder(); - final PlaylistImagesGeneratedPayload body = event.getBody(); - final Playlist.PlaylistBuilder baseInfoPlaylistBuilder = baseInfoPlaylist(body, playlistBuilder); - - Playlist playlist = playlistBuilder.build(); - return Mono.just( - new GeneratedPlaylist(playlist, Collections.emptyList()) - ); + new GeneratedPlaylist( + baseInfoPlaylist(body), + getPlaylistItems(body) + )); } @NotNull - private static Playlist.PlaylistBuilder baseInfoPlaylist(final PlaylistImagesGeneratedPayload body, final Playlist.PlaylistBuilder playlistBuilder) { - PlaylistMetaGeneratedPayload meta = body.getParent(); + private static Playlist baseInfoPlaylist(@NotNull final PlaylistImagesGeneratedPayload body) { + final Playlist.PlaylistBuilder playlistBuilder = Playlist.builder(); + final PlaylistMetaGeneratedPayload meta = body.getParent(); final PlaylistId playlistId = PlaylistId.random(); @@ -55,6 +52,16 @@ private static Playlist.PlaylistBuilder baseInfoPlaylist(final PlaylistImagesGen .contextUri(playlistId.asContextUri()) .playlistType(PlaylistType.PUBLIC) .images(Images.of(images)) - .playlistOwner(SONATA_ACCOUNT); + .playlistOwner(SONATA_ACCOUNT) + .build(); + } + + @NotNull + private static List getPlaylistItems(@NotNull final PlaylistImagesGeneratedPayload body) { + final List tracks = body.getParent().getParent().getTracks(); + + return tracks.stream().map(track -> new GeneratedPlaylist.Item( + track.getTrackId(), PlayableItemType.TRACK, track.getIndex() + )).toList(); } } diff --git a/src/test/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationServiceTest.java b/src/test/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationServiceTest.java index c7d0440..d302e96 100644 --- a/src/test/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationServiceTest.java +++ b/src/test/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationServiceTest.java @@ -1,9 +1,6 @@ package com.odeyalo.sonata.playlists.service.generation; -import com.odeyalo.sonata.playlists.model.EntityType; -import com.odeyalo.sonata.playlists.model.Image; -import com.odeyalo.sonata.playlists.model.Playlist; -import com.odeyalo.sonata.playlists.model.PlaylistType; +import com.odeyalo.sonata.playlists.model.*; import com.odeyalo.sonata.suite.brokers.events.playlist.gen.GeneratedPlaylistType; import com.odeyalo.sonata.suite.brokers.events.playlist.gen.PlaylistImagesGeneratedEvent; import com.odeyalo.sonata.suite.brokers.events.playlist.gen.payload.GeneratedTrack; @@ -13,6 +10,7 @@ import org.junit.jupiter.api.Test; import reactor.test.StepVerifier; +import java.time.Instant; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -98,8 +96,41 @@ void shouldSetThePlaylistOwner() { GeneratedPlaylist generatedPlaylist = testable.generate(event).block(); assertThat(generatedPlaylist).isNotNull(); - assertThat(generatedPlaylist.meta().getPlaylistOwner().getId()).isEqualTo("sonata"); - assertThat(generatedPlaylist.meta().getPlaylistOwner().getDisplayName()).isEqualTo("Sonata"); - assertThat(generatedPlaylist.meta().getPlaylistOwner().getEntityType()).isEqualTo(EntityType.USER); + final PlaylistOwner playlistOwner = generatedPlaylist.meta().getPlaylistOwner(); + + assertThat(playlistOwner.getId()).isEqualTo("sonata"); + assertThat(playlistOwner.getDisplayName()).isEqualTo("Sonata"); + assertThat(playlistOwner.getEntityType()).isEqualTo(EntityType.USER); + } + + @Test + void shouldSetTheTracks() { + // given + final PlaylistImagesGeneratedEvent event = new PlaylistImagesGeneratedEvent( + new PlaylistImagesGeneratedPayload(new PlaylistMetaGeneratedPayload( + new PlaylistTracksGeneratedPayload("123", List.of( + new GeneratedTrack("1", 0), + new GeneratedTrack("2", 1), + new GeneratedTrack("3", 2) + )), + new PlaylistMetaGeneratedPayload.Meta("On Repeat", "Songs you love the most") + ), List.of( + new PlaylistImagesGeneratedPayload.Image("https://cdn.sonata.com/i/c/abc123", 50, 50) + )), GeneratedPlaylistType.ON_REPEAT); + + final PlaylistGenerationService testable = new PlaylistGenerationService(); + + // when + GeneratedPlaylist generatedPlaylist = testable.generate(event).block(); + + // then + assertThat(generatedPlaylist).isNotNull(); + + final List tracks = generatedPlaylist.tracks(); + assertThat(tracks).containsExactlyInAnyOrder( + new GeneratedPlaylist.Item("1", PlayableItemType.TRACK, 0), + new GeneratedPlaylist.Item("2", PlayableItemType.TRACK, 1), + new GeneratedPlaylist.Item("3", PlayableItemType.TRACK, 2) + ); } } \ No newline at end of file From f4102b70e70e8545c68d0158271db350e32539d9 Mon Sep 17 00:00:00 2001 From: odeyalo Date: Fri, 17 Oct 2025 17:18:39 +0300 Subject: [PATCH 06/21] Add user id for which this playlist was generated --- .../service/generation/GeneratedPlaylist.java | 4 ++- .../generation/PlaylistGenerationService.java | 3 ++- .../PlaylistGenerationServiceTest.java | 26 +++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/odeyalo/sonata/playlists/service/generation/GeneratedPlaylist.java b/src/main/java/com/odeyalo/sonata/playlists/service/generation/GeneratedPlaylist.java index 39ce7f0..062753c 100644 --- a/src/main/java/com/odeyalo/sonata/playlists/service/generation/GeneratedPlaylist.java +++ b/src/main/java/com/odeyalo/sonata/playlists/service/generation/GeneratedPlaylist.java @@ -11,10 +11,12 @@ * A meta that was auto-generated by Sonata * @param meta - generated meta info * @param tracks - tracks in this meta + * @param generatedFor - user id for which this playlist was generated */ public record GeneratedPlaylist( @NotNull Playlist meta, - @NotNull List tracks + @NotNull List tracks, + @NotNull String generatedFor ) { public record Item( diff --git a/src/main/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationService.java b/src/main/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationService.java index 3ed1ca2..dd5bd28 100644 --- a/src/main/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationService.java +++ b/src/main/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationService.java @@ -29,7 +29,8 @@ public Mono generate(@NotNull final PlaylistImagesGeneratedEv return Mono.just( new GeneratedPlaylist( baseInfoPlaylist(body), - getPlaylistItems(body) + getPlaylistItems(body), + event.getBody().getParent().getParent().getUserId() )); } diff --git a/src/test/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationServiceTest.java b/src/test/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationServiceTest.java index d302e96..f855b7f 100644 --- a/src/test/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationServiceTest.java +++ b/src/test/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationServiceTest.java @@ -133,4 +133,30 @@ void shouldSetTheTracks() { new GeneratedPlaylist.Item("3", PlayableItemType.TRACK, 2) ); } + + @Test + void shouldSetUserForWhichTrackWasGenerated() { + // given + final PlaylistImagesGeneratedEvent event = new PlaylistImagesGeneratedEvent( + new PlaylistImagesGeneratedPayload(new PlaylistMetaGeneratedPayload( + new PlaylistTracksGeneratedPayload("123", List.of( + new GeneratedTrack("1", 0), + new GeneratedTrack("2", 1), + new GeneratedTrack("3", 2) + )), + new PlaylistMetaGeneratedPayload.Meta("On Repeat", "Songs you love the most") + ), List.of( + new PlaylistImagesGeneratedPayload.Image("https://cdn.sonata.com/i/c/abc123", 50, 50) + )), GeneratedPlaylistType.ON_REPEAT); + + final PlaylistGenerationService testable = new PlaylistGenerationService(); + + // when + GeneratedPlaylist generatedPlaylist = testable.generate(event).block(); + + // then + assertThat(generatedPlaylist).isNotNull(); + + assertThat(generatedPlaylist.generatedFor()).isEqualTo("123"); + } } \ No newline at end of file From 36b0875f2907af6d8d080dc0de3ef525d15086d5 Mon Sep 17 00:00:00 2001 From: odeyalo Date: Sat, 18 Oct 2025 15:43:29 +0300 Subject: [PATCH 07/21] Save the generated playlist to DB --- ...aylistImageOnMissingAfterSaveCallback.java | 26 +++++-- .../service/DefaultPlaylistService.java | 9 +++ .../service/InMemoryPlaylistService.java | 6 ++ .../playlists/service/PlaylistService.java | 3 + .../generation/PlaylistGenerationManager.java | 67 +++++++++++++++++++ .../generation/consumer/MessageConsumer.java | 12 ++-- .../service/tracks/PlaylistItemsService.java | 9 +++ .../db/migration/V12__default_sonata_user.sql | 5 ++ 8 files changed, 124 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationManager.java create mode 100644 src/main/resources/db/migration/V12__default_sonata_user.sql diff --git a/src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/callback/write/SavePlaylistImageOnMissingAfterSaveCallback.java b/src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/callback/write/SavePlaylistImageOnMissingAfterSaveCallback.java index 9f3cb73..0f58b93 100644 --- a/src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/callback/write/SavePlaylistImageOnMissingAfterSaveCallback.java +++ b/src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/callback/write/SavePlaylistImageOnMissingAfterSaveCallback.java @@ -40,17 +40,31 @@ public Publisher onAfterSave(@NotNull final PlaylistEntity entit @NotNull private Mono> saveImages(PlaylistEntity playlist) { List images = playlist.getImages(); + return Flux.fromIterable(images) - .filterWhen(this::isImageNotExist) - .flatMap(entity -> playlistImagesRepository.deleteAllByPlaylistId(playlist.getId()).thenReturn(entity)) - .flatMap(r2DbcImageRepository::save) - .flatMap(imageEntity -> buildAndSave(playlist, imageEntity)) + .flatMap(image -> { + Mono imageMono = r2DbcImageRepository.findByUrl(image.getUrl()); + + Mono<@NotNull PlaylistImage> saveImageOnMiss = Mono.defer(() -> playlistImagesRepository.deleteAllByPlaylistId(playlist.getId()).thenReturn(image)) + .flatMap(r2DbcImageRepository::save) + .flatMap(imageEntity -> buildAndSave(playlist, imageEntity)); + return imageMono + // image exists + .flatMap(it -> buildAndSave(playlist, it)) + // image does not exist + .switchIfEmpty(saveImageOnMiss); + + }) .collectList(); } @NotNull - private Mono buildAndSave(PlaylistEntity parent, ImageEntity imageEntity) { - PlaylistImage imageToSave = PlaylistImage.builder().imageId(imageEntity.getId()).playlistId(parent.getId()).build(); + private Mono buildAndSave(@NotNull final PlaylistEntity parent, + @NotNull final ImageEntity imageEntity) { + final PlaylistImage imageToSave = PlaylistImage.builder() + .imageId(imageEntity.getId()) + .playlistId(parent.getId()) + .build(); return playlistImagesRepository.save(imageToSave); } diff --git a/src/main/java/com/odeyalo/sonata/playlists/service/DefaultPlaylistService.java b/src/main/java/com/odeyalo/sonata/playlists/service/DefaultPlaylistService.java index 8520077..e39a6dd 100644 --- a/src/main/java/com/odeyalo/sonata/playlists/service/DefaultPlaylistService.java +++ b/src/main/java/com/odeyalo/sonata/playlists/service/DefaultPlaylistService.java @@ -41,6 +41,15 @@ public Mono create(@NotNull final CreatePlaylistInfo playlistInfo, .map(it -> playlist); } + @Override + @NotNull + public Mono save(@NotNull final Playlist playlist) { + final PlaylistEntity playlistEntity = playlistEntityFactory.create(playlist); + + return playlistRepository.save(playlistEntity) + .map(it -> playlist); + } + @Override @NotNull public Mono update(@NotNull final Playlist playlist) { diff --git a/src/main/java/com/odeyalo/sonata/playlists/service/InMemoryPlaylistService.java b/src/main/java/com/odeyalo/sonata/playlists/service/InMemoryPlaylistService.java index 569cbda..6ab05f4 100644 --- a/src/main/java/com/odeyalo/sonata/playlists/service/InMemoryPlaylistService.java +++ b/src/main/java/com/odeyalo/sonata/playlists/service/InMemoryPlaylistService.java @@ -38,6 +38,12 @@ public Mono create(@NotNull final CreatePlaylistInfo playlistInfo, return Mono.fromCallable(() -> doSave(playlist)); } + @Override + @NotNull + public Mono save(@NotNull final Playlist playlist) { + return Mono.fromCallable(() -> doSave(playlist)); + } + @Override @NotNull public Mono update(@NotNull final Playlist playlist) { diff --git a/src/main/java/com/odeyalo/sonata/playlists/service/PlaylistService.java b/src/main/java/com/odeyalo/sonata/playlists/service/PlaylistService.java index beccaa8..4f7d13e 100644 --- a/src/main/java/com/odeyalo/sonata/playlists/service/PlaylistService.java +++ b/src/main/java/com/odeyalo/sonata/playlists/service/PlaylistService.java @@ -23,6 +23,9 @@ public interface PlaylistService extends PlaylistLoader { Mono create(@NotNull CreatePlaylistInfo playlistInfo, @NotNull PlaylistOwner owner); + @NotNull + Mono save(@NotNull Playlist playlist); + /** * Update existing playlist with new values * diff --git a/src/main/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationManager.java b/src/main/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationManager.java new file mode 100644 index 0000000..9a111b4 --- /dev/null +++ b/src/main/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationManager.java @@ -0,0 +1,67 @@ +package com.odeyalo.sonata.playlists.service.generation; + +import com.odeyalo.sonata.common.context.ContextUri; +import com.odeyalo.sonata.playlists.model.EntityType; +import com.odeyalo.sonata.playlists.model.PlaylistCollaborator; +import com.odeyalo.sonata.playlists.model.PlaylistItemPosition; +import com.odeyalo.sonata.playlists.model.SimplePlaylistItem; +import com.odeyalo.sonata.playlists.service.PlaylistService; +import com.odeyalo.sonata.playlists.service.tracks.PlaylistItemsService; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.PlaylistImagesGeneratedEvent; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.payload.PlaylistTracksGeneratedPayload; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Mono; + +import java.util.List; + +@Service +public class PlaylistGenerationManager { + private final PlaylistGenerationService generationService; + private final PlaylistItemsService playlistItemsService; + private final PlaylistService playlistService; + private final Logger logger = LoggerFactory.getLogger(PlaylistGenerationManager.class); + + private static final PlaylistCollaborator SONATA_COLLABORATOR = PlaylistCollaborator.builder() + .id("sonata") + .displayName("Sonata") + .contextUri("sonata:user:sonata") + .type(EntityType.USER) + .build(); + + public PlaylistGenerationManager(final PlaylistGenerationService generationService, + final PlaylistService playlistService, + final PlaylistItemsService playlistItemsService) { + this.generationService = generationService; + this.playlistItemsService = playlistItemsService; + this.playlistService = playlistService; + } + + @NotNull + @Transactional(isolation = Isolation.READ_UNCOMMITTED) + public Mono handle(@NotNull final PlaylistImagesGeneratedEvent event) { + final PlaylistTracksGeneratedPayload parentEvent = event.getBody().getParent().getParent(); + logger.info("Starting playlist generation for user {} with type: {}", parentEvent.getUserId(), event.getType()); + + return generationService.generate(event) + .flatMap(generatedPlaylist -> { + return playlistService.save(generatedPlaylist.meta()) + .flatMap(playlist -> { + List tracks = generatedPlaylist.tracks(); + + List items = tracks.stream().map(item -> new SimplePlaylistItem( + playlist.getId(), + SONATA_COLLABORATOR, + ContextUri.forTrack(item.id()), + PlaylistItemPosition.at(item.index()) + )).toList(); + + return playlistItemsService.insertAll(items); + }); + }); + } +} diff --git a/src/main/java/com/odeyalo/sonata/playlists/service/generation/consumer/MessageConsumer.java b/src/main/java/com/odeyalo/sonata/playlists/service/generation/consumer/MessageConsumer.java index 8b2a2c9..0d152c9 100644 --- a/src/main/java/com/odeyalo/sonata/playlists/service/generation/consumer/MessageConsumer.java +++ b/src/main/java/com/odeyalo/sonata/playlists/service/generation/consumer/MessageConsumer.java @@ -1,7 +1,6 @@ package com.odeyalo.sonata.playlists.service.generation.consumer; -import com.odeyalo.sonata.playlists.repository.PlaylistRepository; -import com.odeyalo.sonata.playlists.service.PlaylistService; +import com.odeyalo.sonata.playlists.service.generation.PlaylistGenerationManager; import com.odeyalo.sonata.suite.brokers.events.playlist.gen.PlaylistImagesGeneratedEvent; import jakarta.annotation.PostConstruct; import lombok.extern.log4j.Log4j2; @@ -15,21 +14,20 @@ @Log4j2 public final class MessageConsumer { private final ReactiveKafkaConsumerTemplate reactiveKafkaConsumerTemplate; - private final PlaylistService playlistService; - private PlaylistRepository playlistRepository; + private final PlaylistGenerationManager playlistGenerationManager; // TODO: save the received playlist to database public MessageConsumer(final ReactiveKafkaConsumerTemplate reactiveKafkaConsumerTemplate, - final PlaylistService playlistService) { + final PlaylistGenerationManager playlistGenerationManager) { this.reactiveKafkaConsumerTemplate = reactiveKafkaConsumerTemplate; - this.playlistService = playlistService; + this.playlistGenerationManager = playlistGenerationManager; } @PostConstruct public Disposable consumeRecord() { return reactiveKafkaConsumerTemplate.receive() .map(ReceiverRecord::value) - .doOnNext(msg -> log.info("Received: {}", msg)) + .flatMap(playlistGenerationManager::handle) .doOnError(error -> log.error("Consumer error: {}", error.getMessage())) .subscribeOn(Schedulers.boundedElastic()) .subscribe(); diff --git a/src/main/java/com/odeyalo/sonata/playlists/service/tracks/PlaylistItemsService.java b/src/main/java/com/odeyalo/sonata/playlists/service/tracks/PlaylistItemsService.java index c444c94..db3d0dc 100644 --- a/src/main/java/com/odeyalo/sonata/playlists/service/tracks/PlaylistItemsService.java +++ b/src/main/java/com/odeyalo/sonata/playlists/service/tracks/PlaylistItemsService.java @@ -14,6 +14,8 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import java.util.List; + /** * Used to work with playlist items, middleware between repository but returns a domain models instead of entities */ @@ -57,6 +59,13 @@ public Mono insertItemAtSpecificPosition(@NotNull final SimplePlaylistItem .then(saveItem(playlistItem)); } + @NotNull + public Mono insertAll(@NotNull final List items) { + return Flux.fromIterable(items) + .flatMap(this::saveItem) + .then(); + } + @NotNull private Mono saveItem(@NotNull final SimplePlaylistItem playlistItem) { diff --git a/src/main/resources/db/migration/V12__default_sonata_user.sql b/src/main/resources/db/migration/V12__default_sonata_user.sql new file mode 100644 index 0000000..b868b78 --- /dev/null +++ b/src/main/resources/db/migration/V12__default_sonata_user.sql @@ -0,0 +1,5 @@ +INSERT INTO playlist_collaborators(id, public_id, display_name, entity_type, context_uri) +VALUES (1, 'sonata', 'Sonata', 'USER', 'sonata:user:sonata'); + +INSERT INTO playlist_owner(id, public_id, display_name, entity_type) +VALUES (1, 'sonata', 'Sonata', 'USER'); \ No newline at end of file From 2c894cc39bc5c9a012eee0d9f74d89c47c054a42 Mon Sep 17 00:00:00 2001 From: odeyalo Date: Sat, 18 Oct 2025 15:43:45 +0300 Subject: [PATCH 08/21] Add authentication configuration for local dev --- .../local/AuthenticationConfiguration.java | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 src/main/java/com/odeyalo/sonata/playlists/config/security/local/AuthenticationConfiguration.java diff --git a/src/main/java/com/odeyalo/sonata/playlists/config/security/local/AuthenticationConfiguration.java b/src/main/java/com/odeyalo/sonata/playlists/config/security/local/AuthenticationConfiguration.java new file mode 100644 index 0000000..5549298 --- /dev/null +++ b/src/main/java/com/odeyalo/sonata/playlists/config/security/local/AuthenticationConfiguration.java @@ -0,0 +1,72 @@ +package com.odeyalo.sonata.playlists.config.security.local; + +import com.odeyalo.suite.security.auth.TokenAuthenticationManager; +import com.odeyalo.suite.security.auth.token.AccessTokenMetadata; +import com.odeyalo.suite.security.auth.token.ReactiveAccessTokenValidator; +import com.odeyalo.suite.security.auth.token.ValidatedAccessToken; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.context.annotation.Profile; +import org.springframework.security.authentication.ReactiveAuthenticationManager; +import reactor.core.publisher.Mono; + +import java.time.Instant; +import java.util.Map; + +@Configuration +@Profile("local") +public class AuthenticationConfiguration { + + @Bean + @Primary + public ReactiveAuthenticationManager reactiveAuthenticationManager() { + return new TokenAuthenticationManager( + new LocalDevelopmentAccessTokenValidator() + ); + } + + private static class LocalDevelopmentAccessTokenValidator implements ReactiveAccessTokenValidator { + private final Map tokensCache = Map.of( + "token1", ValidatedAccessToken.valid( + AccessTokenMetadata.of("123", new String[]{"read", "write"}, + Instant.now().getEpochSecond(), + Instant.now().plusSeconds(600).getEpochSecond() + )), + "token1_2", ValidatedAccessToken.valid( + AccessTokenMetadata.of("123", new String[]{"read", "write", "playlist"}, + Instant.now().getEpochSecond(), + Instant.now().plusSeconds(600).getEpochSecond() + )), + "token2", ValidatedAccessToken.valid( + AccessTokenMetadata.of("miku", new String[]{"read", "write", "playlist"}, + Instant.now().getEpochSecond(), + Instant.now().plusSeconds(600).getEpochSecond() + )) + ); + + private final Logger logger = LoggerFactory.getLogger(LocalDevelopmentAccessTokenValidator.class); + + public LocalDevelopmentAccessTokenValidator() { + logger.info("Using local 'DEV' mode, cache of available access tokens to use. Token that is not exist in cache will cause HTTP 401 UNAUTHORIZED status"); + + tokensCache.forEach((key, value) -> { + final AccessTokenMetadata tokenMetadata = value.getToken(); + logger.info("Generated access token: '{}' for user 'ID({})' with following scopes: '({})', expire after: {} seconds", key, tokenMetadata.getUserId(), tokenMetadata.getScopes(), tokenMetadata.getExpiresIn() - Instant.now().getEpochSecond()); + }); + + } + + @Override + @NotNull + public Mono validateToken(@NotNull final String tokenValue) { + + final ValidatedAccessToken validatedAccessToken = tokensCache.getOrDefault(tokenValue, ValidatedAccessToken.invalid()); + + return Mono.just(validatedAccessToken); + } + } +} From b12a7e51be1798cea28e9b8bcb44948986f9fd63 Mon Sep 17 00:00:00 2001 From: odeyalo Date: Sat, 18 Oct 2025 16:09:33 +0300 Subject: [PATCH 09/21] Sequence should update the ID --- .../resources/db/migration/V12__default_sonata_user.sql | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/resources/db/migration/V12__default_sonata_user.sql b/src/main/resources/db/migration/V12__default_sonata_user.sql index b868b78..4b83cd0 100644 --- a/src/main/resources/db/migration/V12__default_sonata_user.sql +++ b/src/main/resources/db/migration/V12__default_sonata_user.sql @@ -1,5 +1,5 @@ -INSERT INTO playlist_collaborators(id, public_id, display_name, entity_type, context_uri) -VALUES (1, 'sonata', 'Sonata', 'USER', 'sonata:user:sonata'); +INSERT INTO playlist_collaborators(public_id, display_name, entity_type, context_uri) +VALUES ('sonata', 'Sonata', 'USER', 'sonata:user:sonata'); -INSERT INTO playlist_owner(id, public_id, display_name, entity_type) -VALUES (1, 'sonata', 'Sonata', 'USER'); \ No newline at end of file +INSERT INTO playlist_owner(public_id, display_name, entity_type) +VALUES ('sonata', 'Sonata', 'USER'); \ No newline at end of file From f1477721776efe413ff6b1affbe7b9ff4841bf22 Mon Sep 17 00:00:00 2001 From: odeyalo Date: Sun, 19 Oct 2025 00:12:26 +0300 Subject: [PATCH 10/21] Create generated playlist table and java representation for it --- .../entity/GeneratedPlaylistEntity.java | 36 ++++++++ .../GeneratedPlaylistRepository.java | 38 +++++++++ .../R2dbcGeneratedPlaylistRepository.java | 26 ++++++ ...bcGeneratedPlaylistRepositoryDelegate.java | 15 ++++ .../V13__create_generated_playlists_table.sql | 12 +++ ...neratedPlaylistRepositoryDelegateTest.java | 82 +++++++++++++++++++ 6 files changed, 209 insertions(+) create mode 100644 src/main/java/com/odeyalo/sonata/playlists/entity/GeneratedPlaylistEntity.java create mode 100644 src/main/java/com/odeyalo/sonata/playlists/repository/GeneratedPlaylistRepository.java create mode 100644 src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/R2dbcGeneratedPlaylistRepository.java create mode 100644 src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/delegate/R2dbcGeneratedPlaylistRepositoryDelegate.java create mode 100644 src/main/resources/db/migration/V13__create_generated_playlists_table.sql create mode 100644 src/test/java/com/odeyalo/sonata/playlists/repository/r2dbc/delegate/R2dbcGeneratedPlaylistRepositoryDelegateTest.java diff --git a/src/main/java/com/odeyalo/sonata/playlists/entity/GeneratedPlaylistEntity.java b/src/main/java/com/odeyalo/sonata/playlists/entity/GeneratedPlaylistEntity.java new file mode 100644 index 0000000..0de28fe --- /dev/null +++ b/src/main/java/com/odeyalo/sonata/playlists/entity/GeneratedPlaylistEntity.java @@ -0,0 +1,36 @@ +package com.odeyalo.sonata.playlists.entity; + +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.GeneratedPlaylistType; +import lombok.*; +import lombok.experimental.FieldDefaults; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.data.annotation.Id; +import org.springframework.data.relational.core.mapping.Column; +import org.springframework.data.relational.core.mapping.Table; + +import java.time.Instant; + +@Data +@AllArgsConstructor(staticName = "of") +@NoArgsConstructor +@Builder +@FieldDefaults(level = AccessLevel.PRIVATE) +@Table(name = "generated_playlists") +public class GeneratedPlaylistEntity { + @Id + @Nullable + Long id; + @NotNull + @Column("playlist_id") + String playlistId; + @NotNull + @Column("generated_for") + String userId; + @Nullable + @Column("playlist_type") + GeneratedPlaylistType generatedPlaylistType; + @NotNull + @Column("generated_at") + Instant generatedAt; +} diff --git a/src/main/java/com/odeyalo/sonata/playlists/repository/GeneratedPlaylistRepository.java b/src/main/java/com/odeyalo/sonata/playlists/repository/GeneratedPlaylistRepository.java new file mode 100644 index 0000000..a839aad --- /dev/null +++ b/src/main/java/com/odeyalo/sonata/playlists/repository/GeneratedPlaylistRepository.java @@ -0,0 +1,38 @@ +package com.odeyalo.sonata.playlists.repository; + +import com.odeyalo.sonata.playlists.entity.GeneratedPlaylistEntity; +import com.odeyalo.sonata.playlists.model.Playlist; +import org.jetbrains.annotations.NotNull; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Base interface to work with {@link com.odeyalo.sonata.playlists.entity.GeneratedPlaylistEntity} + */ +public interface GeneratedPlaylistRepository { + /** + * Save or update the given GeneratedPlaylistEntity to the repository + * + * @param playlist - GeneratedPlaylistEntity to save + * @return saved GeneratedPlaylistEntity + */ + @NotNull + Mono save(@NotNull GeneratedPlaylistEntity playlist); + + /** + * Search for the GeneratedPlaylistEntity for the user + * + * @param userId - user id to use for search + * @return {@link Flux} with found {@link GeneratedPlaylistEntity} + */ + @NotNull + Flux findByGeneratedFor(@NotNull String userId); + + /** + * Clear the repository. Commonly used in tests + * + * @return - empty mono + */ + @NotNull + Mono clear(); +} diff --git a/src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/R2dbcGeneratedPlaylistRepository.java b/src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/R2dbcGeneratedPlaylistRepository.java new file mode 100644 index 0000000..59f0fa3 --- /dev/null +++ b/src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/R2dbcGeneratedPlaylistRepository.java @@ -0,0 +1,26 @@ +package com.odeyalo.sonata.playlists.repository.r2dbc; + +import com.odeyalo.sonata.playlists.entity.GeneratedPlaylistEntity; +import com.odeyalo.sonata.playlists.repository.GeneratedPlaylistRepository; +import org.jetbrains.annotations.NotNull; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +@Component +public final class R2dbcGeneratedPlaylistRepository implements GeneratedPlaylistRepository { + @Override + public @NotNull Mono save(@NotNull final GeneratedPlaylistEntity playlist) { + return null; + } + + @Override + public @NotNull Flux findByGeneratedFor(@NotNull final String userId) { + return null; + } + + @Override + public @NotNull Mono clear() { + return null; + } +} diff --git a/src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/delegate/R2dbcGeneratedPlaylistRepositoryDelegate.java b/src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/delegate/R2dbcGeneratedPlaylistRepositoryDelegate.java new file mode 100644 index 0000000..2b5d358 --- /dev/null +++ b/src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/delegate/R2dbcGeneratedPlaylistRepositoryDelegate.java @@ -0,0 +1,15 @@ +package com.odeyalo.sonata.playlists.repository.r2dbc.delegate; + +import com.odeyalo.sonata.playlists.entity.GeneratedPlaylistEntity; +import org.jetbrains.annotations.NotNull; +import org.springframework.data.r2dbc.repository.R2dbcRepository; +import org.springframework.stereotype.Repository; +import reactor.core.publisher.Flux; + +@Repository +public interface R2dbcGeneratedPlaylistRepositoryDelegate extends R2dbcRepository { + + @NotNull + Flux findByUserId(@NotNull String userId); + +} diff --git a/src/main/resources/db/migration/V13__create_generated_playlists_table.sql b/src/main/resources/db/migration/V13__create_generated_playlists_table.sql new file mode 100644 index 0000000..722b5b1 --- /dev/null +++ b/src/main/resources/db/migration/V13__create_generated_playlists_table.sql @@ -0,0 +1,12 @@ +CREATE TABLE generated_playlists +( + id SERIAL PRIMARY KEY, +-- A public User ID for which this playlist was generated for + generated_for VARCHAR(30) NOT NULL, +-- We know that public playlist ID is always unique and can't be changed. +-- Use public ID instead of internal primary key for performance reasons, +-- to skip non-mandatory JOIN and SELECT queries, and additional INSERT as well + playlist_id VARCHAR(255) NOT NULL REFERENCES playlists(public_id), + generated_at timestamp NOT NULL, + playlist_type VARCHAR(100) +); diff --git a/src/test/java/com/odeyalo/sonata/playlists/repository/r2dbc/delegate/R2dbcGeneratedPlaylistRepositoryDelegateTest.java b/src/test/java/com/odeyalo/sonata/playlists/repository/r2dbc/delegate/R2dbcGeneratedPlaylistRepositoryDelegateTest.java new file mode 100644 index 0000000..394fc9e --- /dev/null +++ b/src/test/java/com/odeyalo/sonata/playlists/repository/r2dbc/delegate/R2dbcGeneratedPlaylistRepositoryDelegateTest.java @@ -0,0 +1,82 @@ +package com.odeyalo.sonata.playlists.repository.r2dbc.delegate; + +import com.odeyalo.sonata.playlists.entity.GeneratedPlaylistEntity; +import com.odeyalo.sonata.playlists.entity.PlaylistEntity; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.GeneratedPlaylistType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.data.r2dbc.DataR2dbcTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import testing.faker.PlaylistEntityFaker; +import testing.spring.R2dbcCallbacksConfiguration; + +import java.util.List; + +import static java.time.Instant.now; +import static org.assertj.core.api.Assertions.assertThat; + +@DataR2dbcTest +@ActiveProfiles("test") +@Import(R2dbcCallbacksConfiguration.class) +class R2dbcGeneratedPlaylistRepositoryDelegateTest { + + @Autowired + R2dbcPlaylistRepositoryDelegate r2dbcPlaylistRepositoryDelegate; + + @Autowired + R2dbcGeneratedPlaylistRepositoryDelegate testable; + + public static final String PLAYLIST_ID_1 = "mikuuu"; + public static final String PLAYLIST_ID_2 = "nakano"; + + @BeforeEach + void setUp() { + final PlaylistEntity playlist1 = PlaylistEntityFaker.createWithNoId() + .setPublicId(PLAYLIST_ID_1) + .get(); + + final PlaylistEntity playlist2 = PlaylistEntityFaker.createWithNoId() + .setPublicId(PLAYLIST_ID_2) + .get(); + + r2dbcPlaylistRepositoryDelegate.save(playlist1).block(); + r2dbcPlaylistRepositoryDelegate.save(playlist2).block(); + } + + @AfterEach + void tearDown() { + testable.deleteAll().block(); + r2dbcPlaylistRepositoryDelegate.deleteAll().block(); + } + + @Test + void shouldFindTheGeneratedPlaylistForUser() { + testable.save( + GeneratedPlaylistEntity.builder() + .playlistId(PLAYLIST_ID_1) + .userId("odeyalo") + .generatedPlaylistType(GeneratedPlaylistType.ON_REPEAT) + .generatedAt(now()) + .build() + ).block(); + + testable.save( + GeneratedPlaylistEntity.builder() + .playlistId(PLAYLIST_ID_2) + .userId("odeyalo") + .generatedPlaylistType(GeneratedPlaylistType.ON_REPEAT) + .generatedAt(now()) + .build() + ).block(); + + + final List result = testable.findByUserId("odeyalo").collectList().block(); + + assertThat(result) + .extracting(GeneratedPlaylistEntity::getPlaylistId) + .containsExactlyInAnyOrder(PLAYLIST_ID_1, PLAYLIST_ID_2); + } +} \ No newline at end of file From 374f16fdda2f0cc93f06690f778a419f12421d37 Mon Sep 17 00:00:00 2001 From: odeyalo Date: Mon, 20 Oct 2025 21:45:33 +0300 Subject: [PATCH 11/21] Save generated playlist to DB --- .../R2dbcGeneratedPlaylistRepository.java | 22 +++++--- .../generation/PlaylistGenerationManager.java | 54 ++++++++++++------- 2 files changed, 52 insertions(+), 24 deletions(-) diff --git a/src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/R2dbcGeneratedPlaylistRepository.java b/src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/R2dbcGeneratedPlaylistRepository.java index 59f0fa3..48d86a6 100644 --- a/src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/R2dbcGeneratedPlaylistRepository.java +++ b/src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/R2dbcGeneratedPlaylistRepository.java @@ -2,6 +2,7 @@ import com.odeyalo.sonata.playlists.entity.GeneratedPlaylistEntity; import com.odeyalo.sonata.playlists.repository.GeneratedPlaylistRepository; +import com.odeyalo.sonata.playlists.repository.r2dbc.delegate.R2dbcGeneratedPlaylistRepositoryDelegate; import org.jetbrains.annotations.NotNull; import org.springframework.stereotype.Component; import reactor.core.publisher.Flux; @@ -9,18 +10,27 @@ @Component public final class R2dbcGeneratedPlaylistRepository implements GeneratedPlaylistRepository { + private final R2dbcGeneratedPlaylistRepositoryDelegate delegate; + + public R2dbcGeneratedPlaylistRepository(final R2dbcGeneratedPlaylistRepositoryDelegate delegate) { + this.delegate = delegate; + } + @Override - public @NotNull Mono save(@NotNull final GeneratedPlaylistEntity playlist) { - return null; + @NotNull + public Mono save(@NotNull final GeneratedPlaylistEntity playlist) { + return delegate.save(playlist); } @Override - public @NotNull Flux findByGeneratedFor(@NotNull final String userId) { - return null; + @NotNull + public Flux findByGeneratedFor(@NotNull final String userId) { + return delegate.findByUserId(userId); } @Override - public @NotNull Mono clear() { - return null; + @NotNull + public Mono clear() { + return delegate.deleteAll(); } } diff --git a/src/main/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationManager.java b/src/main/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationManager.java index 9a111b4..33cd004 100644 --- a/src/main/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationManager.java +++ b/src/main/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationManager.java @@ -1,10 +1,9 @@ package com.odeyalo.sonata.playlists.service.generation; import com.odeyalo.sonata.common.context.ContextUri; -import com.odeyalo.sonata.playlists.model.EntityType; -import com.odeyalo.sonata.playlists.model.PlaylistCollaborator; -import com.odeyalo.sonata.playlists.model.PlaylistItemPosition; -import com.odeyalo.sonata.playlists.model.SimplePlaylistItem; +import com.odeyalo.sonata.playlists.entity.GeneratedPlaylistEntity; +import com.odeyalo.sonata.playlists.model.*; +import com.odeyalo.sonata.playlists.repository.GeneratedPlaylistRepository; import com.odeyalo.sonata.playlists.service.PlaylistService; import com.odeyalo.sonata.playlists.service.tracks.PlaylistItemsService; import com.odeyalo.sonata.suite.brokers.events.playlist.gen.PlaylistImagesGeneratedEvent; @@ -17,6 +16,7 @@ import org.springframework.transaction.annotation.Transactional; import reactor.core.publisher.Mono; +import java.time.Instant; import java.util.List; @Service @@ -24,6 +24,8 @@ public class PlaylistGenerationManager { private final PlaylistGenerationService generationService; private final PlaylistItemsService playlistItemsService; private final PlaylistService playlistService; + private final GeneratedPlaylistRepository generatedPlaylistRepository; + private final Logger logger = LoggerFactory.getLogger(PlaylistGenerationManager.class); private static final PlaylistCollaborator SONATA_COLLABORATOR = PlaylistCollaborator.builder() @@ -35,10 +37,11 @@ public class PlaylistGenerationManager { public PlaylistGenerationManager(final PlaylistGenerationService generationService, final PlaylistService playlistService, - final PlaylistItemsService playlistItemsService) { + final PlaylistItemsService playlistItemsService, final GeneratedPlaylistRepository generatedPlaylistRepository) { this.generationService = generationService; this.playlistItemsService = playlistItemsService; this.playlistService = playlistService; + this.generatedPlaylistRepository = generatedPlaylistRepository; } @NotNull @@ -48,20 +51,35 @@ public Mono handle(@NotNull final PlaylistImagesGeneratedEvent event) { logger.info("Starting playlist generation for user {} with type: {}", parentEvent.getUserId(), event.getType()); return generationService.generate(event) - .flatMap(generatedPlaylist -> { - return playlistService.save(generatedPlaylist.meta()) - .flatMap(playlist -> { - List tracks = generatedPlaylist.tracks(); + .flatMap(generatedPlaylist -> { + return playlistService.save(generatedPlaylist.meta()) + .flatMap(playlist -> { + final List items = getTracks(generatedPlaylist, playlist); + + return playlistItemsService.insertAll(items) + .then(Mono.defer(() -> generatedPlaylistRepository.save( + GeneratedPlaylistEntity.builder() + .userId(event.getBody().getParent().getParent().getUserId()) + .playlistId(playlist.getId().value()) + .generatedAt(Instant.now()) + .generatedPlaylistType(event.getType()) + .build() + ))) + .then(); + }); + }); + } - List items = tracks.stream().map(item -> new SimplePlaylistItem( - playlist.getId(), - SONATA_COLLABORATOR, - ContextUri.forTrack(item.id()), - PlaylistItemPosition.at(item.index()) - )).toList(); + @NotNull + private static List getTracks(final GeneratedPlaylist generatedPlaylist, + final Playlist playlist) { + List tracks = generatedPlaylist.tracks(); - return playlistItemsService.insertAll(items); - }); - }); + return tracks.stream().map(item -> new SimplePlaylistItem( + playlist.getId(), + SONATA_COLLABORATOR, + ContextUri.forTrack(item.id()), + PlaylistItemPosition.at(item.index()) + )).toList(); } } From 3fbae64b0b1e3f4c3b44c8252095cb2f2bcd85da Mon Sep 17 00:00:00 2001 From: odeyalo Date: Mon, 20 Oct 2025 21:46:40 +0300 Subject: [PATCH 12/21] Use upsert instead of save() --- .../r2dbc/R2dbcImageRepository.java | 14 +++++++++++++ ...aylistImageOnMissingAfterSaveCallback.java | 21 ++----------------- 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/R2dbcImageRepository.java b/src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/R2dbcImageRepository.java index 0dd0a8e..cd1a34a 100644 --- a/src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/R2dbcImageRepository.java +++ b/src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/R2dbcImageRepository.java @@ -1,6 +1,9 @@ package com.odeyalo.sonata.playlists.repository.r2dbc; import com.odeyalo.sonata.playlists.entity.ImageEntity; +import org.jetbrains.annotations.NotNull; +import org.springframework.data.r2dbc.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.data.repository.reactive.ReactiveCrudRepository; import org.springframework.stereotype.Repository; import reactor.core.publisher.Mono; @@ -9,4 +12,15 @@ public interface R2dbcImageRepository extends ReactiveCrudRepository { Mono findByUrl(String url); + + @Query(""" + INSERT INTO images (url, width, height) + VALUES (:#{#image.url}, :#{#image.width}, :#{#image.height}) + ON CONFLICT (url) DO UPDATE + SET url = excluded.url + RETURNING * + """) + @NotNull + Mono upsert(@NotNull @Param("image") ImageEntity image); + } diff --git a/src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/callback/write/SavePlaylistImageOnMissingAfterSaveCallback.java b/src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/callback/write/SavePlaylistImageOnMissingAfterSaveCallback.java index 0f58b93..2479d1d 100644 --- a/src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/callback/write/SavePlaylistImageOnMissingAfterSaveCallback.java +++ b/src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/callback/write/SavePlaylistImageOnMissingAfterSaveCallback.java @@ -1,11 +1,10 @@ package com.odeyalo.sonata.playlists.repository.r2dbc.callback.write; -import com.odeyalo.sonata.playlists.entity.PlaylistImage; import com.odeyalo.sonata.playlists.entity.ImageEntity; import com.odeyalo.sonata.playlists.entity.PlaylistEntity; +import com.odeyalo.sonata.playlists.entity.PlaylistImage; import com.odeyalo.sonata.playlists.repository.PlaylistImagesRepository; import com.odeyalo.sonata.playlists.repository.r2dbc.R2dbcImageRepository; -import org.apache.commons.lang3.BooleanUtils; import org.jetbrains.annotations.NotNull; import org.reactivestreams.Publisher; import org.springframework.context.annotation.Lazy; @@ -43,17 +42,8 @@ private Mono> saveImages(PlaylistEntity playlist) { return Flux.fromIterable(images) .flatMap(image -> { - Mono imageMono = r2DbcImageRepository.findByUrl(image.getUrl()); - - Mono<@NotNull PlaylistImage> saveImageOnMiss = Mono.defer(() -> playlistImagesRepository.deleteAllByPlaylistId(playlist.getId()).thenReturn(image)) - .flatMap(r2DbcImageRepository::save) + return r2DbcImageRepository.upsert(image) .flatMap(imageEntity -> buildAndSave(playlist, imageEntity)); - return imageMono - // image exists - .flatMap(it -> buildAndSave(playlist, it)) - // image does not exist - .switchIfEmpty(saveImageOnMiss); - }) .collectList(); } @@ -67,11 +57,4 @@ private Mono buildAndSave(@NotNull final PlaylistEntity parent, .build(); return playlistImagesRepository.save(imageToSave); } - - @NotNull - private Mono isImageNotExist(ImageEntity entity) { - return r2DbcImageRepository.findByUrl(entity.getUrl()) - .hasElement() - .map(BooleanUtils::negate); - } } From 2c490256c756e326a501562b6bfe4d49b2706571 Mon Sep 17 00:00:00 2001 From: odeyalo Date: Mon, 20 Oct 2025 21:55:01 +0300 Subject: [PATCH 13/21] Change to docker compose --- sonata-playlists-test-run.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sonata-playlists-test-run.sh b/sonata-playlists-test-run.sh index 722fc50..74fa9cb 100644 --- a/sonata-playlists-test-run.sh +++ b/sonata-playlists-test-run.sh @@ -1,3 +1,3 @@ #!/bin/bash -docker-compose -f docker-compose.test.yml up --abort-on-container-exit \ No newline at end of file +docker compose -f docker-compose.test.yml up --abort-on-container-exit \ No newline at end of file From ab3ce8840c2886b3d0d1cad590c7bb72bb4e5697 Mon Sep 17 00:00:00 2001 From: odeyalo Date: Mon, 20 Oct 2025 22:03:24 +0300 Subject: [PATCH 14/21] Fix typo in dependency version --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 0c036a2..8623b7d 100644 --- a/build.gradle +++ b/build.gradle @@ -45,7 +45,7 @@ dependencies { implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client' implementation 'org.jetbrains:annotations:23.0.0' implementation 'org.flywaydb:flyway-core' - implementation 'com.odeyalo.sonata.suite:common:1.0.1`' + implementation 'com.odeyalo.sonata.suite:common:1.0.1' implementation 'com.odeyalo.sonata.suite:suite-brokers:0.0.11' implementation "com.odeyalo.sonata.suite:security:${suiteSecurityVersion}" implementation "org.mapstruct:mapstruct:${mapstructVersion}" From 5b9e6980c4892c42ef7ea6407222b9dc236995ea Mon Sep 17 00:00:00 2001 From: odeyalo Date: Mon, 20 Oct 2025 22:28:02 +0300 Subject: [PATCH 15/21] Add minumum idle prop --- src/test/resources/application-test.properties | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index b21aee2..33761e3 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -5,6 +5,8 @@ spring.contracts.repository.root=git://https://github.com/Project-Sonata/Sonata- spring.main.web-application-type=reactive eureka.client.enabled=false +spring.datasource.hikari.minimum-idle=5 + logging.level.io.r2dbc.postgresql.QUERY=DEBUG logging.level.io.r2dbc.postgresql.PARAM=DEBUG logging.level.org.springframework.cloud.contract=debug From 8d876ceb56fa5c9a892f6cdeff0110abef1aef83 Mon Sep 17 00:00:00 2001 From: odeyalo Date: Mon, 20 Oct 2025 22:31:36 +0300 Subject: [PATCH 16/21] Add spring.datasource.hikari.maximum-pool-size=2 --- src/test/resources/application-test.properties | 1 + 1 file changed, 1 insertion(+) diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index 33761e3..04d1f48 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -6,6 +6,7 @@ spring.main.web-application-type=reactive eureka.client.enabled=false spring.datasource.hikari.minimum-idle=5 +spring.datasource.hikari.maximum-pool-size=2 logging.level.io.r2dbc.postgresql.QUERY=DEBUG logging.level.io.r2dbc.postgresql.PARAM=DEBUG From 853a03fafd90f4a7ba45d5c84c6953d4000f822b Mon Sep 17 00:00:00 2001 From: odeyalo Date: Mon, 20 Oct 2025 22:56:15 +0300 Subject: [PATCH 17/21] More max connections for postgres --- docker-compose.test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 0218c2b..68a3e37 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -21,6 +21,7 @@ services: db: image: 'postgres:15.2' container_name: db + command: -c 'max_connections=200' ports: - "5433:5432" environment: From 81980493da80dfee71b816040f2c64dde8fd72a1 Mon Sep 17 00:00:00 2001 From: odeyalo Date: Mon, 20 Oct 2025 23:19:43 +0300 Subject: [PATCH 18/21] Remove the playlist images on update --- .../write/SavePlaylistImageOnMissingAfterSaveCallback.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/callback/write/SavePlaylistImageOnMissingAfterSaveCallback.java b/src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/callback/write/SavePlaylistImageOnMissingAfterSaveCallback.java index 2479d1d..7b60c49 100644 --- a/src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/callback/write/SavePlaylistImageOnMissingAfterSaveCallback.java +++ b/src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/callback/write/SavePlaylistImageOnMissingAfterSaveCallback.java @@ -42,8 +42,10 @@ private Mono> saveImages(PlaylistEntity playlist) { return Flux.fromIterable(images) .flatMap(image -> { - return r2DbcImageRepository.upsert(image) - .flatMap(imageEntity -> buildAndSave(playlist, imageEntity)); + + return playlistImagesRepository.deleteAllByPlaylistId(playlist.getId()) + .then(Mono.defer(() -> r2DbcImageRepository.upsert(image) + .flatMap(imageEntity -> buildAndSave(playlist, imageEntity)))); }) .collectList(); } From 3982c62c5aaf0acba09fd62f891252e59695edb1 Mon Sep 17 00:00:00 2001 From: odeyalo Date: Sat, 25 Oct 2025 19:38:12 +0300 Subject: [PATCH 19/21] Add tests for PlaylistGenerationManager --- .../generation/PlaylistGenerationManager.java | 36 +++++--- .../PlaylistGenerationManagerTest.java | 89 +++++++++++++++++++ 2 files changed, 111 insertions(+), 14 deletions(-) create mode 100644 src/test/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationManagerTest.java diff --git a/src/main/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationManager.java b/src/main/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationManager.java index 33cd004..e31a3ff 100644 --- a/src/main/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationManager.java +++ b/src/main/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationManager.java @@ -37,7 +37,8 @@ public class PlaylistGenerationManager { public PlaylistGenerationManager(final PlaylistGenerationService generationService, final PlaylistService playlistService, - final PlaylistItemsService playlistItemsService, final GeneratedPlaylistRepository generatedPlaylistRepository) { + final PlaylistItemsService playlistItemsService, + final GeneratedPlaylistRepository generatedPlaylistRepository) { this.generationService = generationService; this.playlistItemsService = playlistItemsService; this.playlistService = playlistService; @@ -54,22 +55,29 @@ public Mono handle(@NotNull final PlaylistImagesGeneratedEvent event) { .flatMap(generatedPlaylist -> { return playlistService.save(generatedPlaylist.meta()) .flatMap(playlist -> { - final List items = getTracks(generatedPlaylist, playlist); - - return playlistItemsService.insertAll(items) - .then(Mono.defer(() -> generatedPlaylistRepository.save( - GeneratedPlaylistEntity.builder() - .userId(event.getBody().getParent().getParent().getUserId()) - .playlistId(playlist.getId().value()) - .generatedAt(Instant.now()) - .generatedPlaylistType(event.getType()) - .build() - ))) - .then(); - }); + return saveGeneratedEvent(event, generatedPlaylist, playlist); + }).then(Mono.defer(() -> Mono.fromRunnable(() -> logger.info("Completed playlist generation for user: {}", parentEvent.getUserId())))); }); } + @NotNull + private Mono saveGeneratedEvent(@NotNull final PlaylistImagesGeneratedEvent event, + @NotNull final GeneratedPlaylist generatedPlaylist, + @NotNull final Playlist playlist) { + final List items = getTracks(generatedPlaylist, playlist); + + return playlistItemsService.insertAll(items) + .then(Mono.defer(() -> generatedPlaylistRepository.save( + GeneratedPlaylistEntity.builder() + .userId(event.getBody().getParent().getParent().getUserId()) + .playlistId(playlist.getId().value()) + .generatedAt(Instant.now()) + .generatedPlaylistType(event.getType()) + .build() + ))) + .then(); + } + @NotNull private static List getTracks(final GeneratedPlaylist generatedPlaylist, final Playlist playlist) { diff --git a/src/test/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationManagerTest.java b/src/test/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationManagerTest.java new file mode 100644 index 0000000..5ed59f0 --- /dev/null +++ b/src/test/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationManagerTest.java @@ -0,0 +1,89 @@ +package com.odeyalo.sonata.playlists.service.generation; + +import com.odeyalo.sonata.playlists.entity.GeneratedPlaylistEntity; +import com.odeyalo.sonata.playlists.entity.PlaylistEntity; +import com.odeyalo.sonata.playlists.entity.PlaylistItemEntity; +import com.odeyalo.sonata.playlists.model.PlaylistId; +import com.odeyalo.sonata.playlists.model.PlaylistType; +import com.odeyalo.sonata.playlists.repository.GeneratedPlaylistRepository; +import com.odeyalo.sonata.playlists.repository.PlaylistItemsRepository; +import com.odeyalo.sonata.playlists.repository.PlaylistRepository; +import com.odeyalo.sonata.playlists.support.pagination.OffsetBasedPageRequest; +import com.odeyalo.sonata.suite.brokers.events.SonataEvent; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.GeneratedPlaylistType; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.PlaylistImagesGeneratedEvent; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.payload.GeneratedTrack; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.payload.PlaylistImagesGeneratedPayload; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.payload.PlaylistMetaGeneratedPayload; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.payload.PlaylistTracksGeneratedPayload; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Comparator; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("test") +public final class PlaylistGenerationManagerTest { + + @Autowired + PlaylistGenerationManager testable; + + @Autowired + GeneratedPlaylistRepository generatedPlaylistRepository; + + @Autowired + PlaylistItemsRepository playlistItemsRepository; + + @Autowired + PlaylistRepository playlistRepository; + + @Test + void shouldCreatePlaylist() { + + final var event = new PlaylistImagesGeneratedEvent(new PlaylistImagesGeneratedPayload( + new PlaylistMetaGeneratedPayload( + new PlaylistTracksGeneratedPayload("u123", List.of( + new GeneratedTrack("1", 0), + new GeneratedTrack("2", 1), + new GeneratedTrack("3", 2) + )), new PlaylistMetaGeneratedPayload.Meta("Cool name", "Cool desc")), + List.of( + new PlaylistImagesGeneratedPayload.Image("https://cdn.sonata.com/i/c/123") + ) + ), GeneratedPlaylistType.ON_REPEAT); + + testable.handle(event).block(); + + GeneratedPlaylistEntity generatedPlaylist = generatedPlaylistRepository.findByGeneratedFor("u123").blockFirst(); + + assertThat(generatedPlaylist).isNotNull(); + assertThat(generatedPlaylist.getGeneratedPlaylistType()).isEqualTo(GeneratedPlaylistType.ON_REPEAT); + + String playlistId = generatedPlaylist.getPlaylistId(); + + PlaylistEntity playlist = playlistRepository.findByPublicId(PlaylistId.of(playlistId)).block(); + + assertThat(playlist).isNotNull(); + assertThat(playlist.getPlaylistName()).isEqualTo("Cool name"); + assertThat(playlist.getPlaylistDescription()).isEqualTo("Cool desc"); + assertThat(playlist.getPlaylistType()).isEqualTo(PlaylistType.PUBLIC); + + List items = playlistItemsRepository.findAllByPlaylistId(playlistId, new OffsetBasedPageRequest(0, 100)) + .sort(Comparator.comparingInt(PlaylistItemEntity::getIndex)) + .collectList() + .block(); + + assertThat(items).isNotEmpty(); + + assertThat(items).map(e -> e.getItem().getPublicId()).containsExactly( + "1", "2", "3" + ); + } + +} From af269117300bda740f33ab05e9801c01b52bcd2e Mon Sep 17 00:00:00 2001 From: odeyalo Date: Sat, 25 Oct 2025 19:41:42 +0300 Subject: [PATCH 20/21] Update gitignore --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index d3e71b0..7422024 100644 --- a/.gitignore +++ b/.gitignore @@ -27,7 +27,9 @@ gradle/** out/ !**/src/main/**/out/ !**/src/test/**/out/ - +**/src/main/generated +!*.env +local.env ### NetBeans ### /nbproject/private/ /nbbuild/ From 280c567a3c723b08b3e6bfaede25f53b91e7a6c2 Mon Sep 17 00:00:00 2001 From: odeyalo Date: Sat, 25 Oct 2025 21:11:14 +0300 Subject: [PATCH 21/21] Use test containers --- build.gradle | 2 +- docker-compose.test.yml | 5 ++- .../PlaylistGenerationManagerTest.java | 9 +---- .../testing/core/AbstractIntegrationTest.java | 38 +++++++++++++++++++ 4 files changed, 45 insertions(+), 9 deletions(-) create mode 100644 src/test/java/testing/core/AbstractIntegrationTest.java diff --git a/build.gradle b/build.gradle index 8623b7d..6330c5d 100644 --- a/build.gradle +++ b/build.gradle @@ -65,7 +65,7 @@ dependencies { testImplementation 'org.springframework.cloud:spring-cloud-contract-stub-runner' testImplementation 'io.r2dbc:r2dbc-h2' testImplementation "com.github.javafaker:javafaker:${javaFakerVersion}" - + testImplementation 'org.testcontainers:postgresql' } dependencyManagement { diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 68a3e37..c7bfbbd 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -17,13 +17,16 @@ services: # Used to download required dependencies from Suite module GITHUB_USERNAME: ${GH_USERNAME} GITHUB_ACCESS_TOKEN: ${GH_ACCESS_TOKEN} + volumes: + - /var/run/docker.sock:/var/run/docker.sock + db: image: 'postgres:15.2' container_name: db command: -c 'max_connections=200' ports: - - "5433:5432" + - "5435:5432" environment: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=root diff --git a/src/test/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationManagerTest.java b/src/test/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationManagerTest.java index 5ed59f0..b55a1f4 100644 --- a/src/test/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationManagerTest.java +++ b/src/test/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationManagerTest.java @@ -9,7 +9,6 @@ import com.odeyalo.sonata.playlists.repository.PlaylistItemsRepository; import com.odeyalo.sonata.playlists.repository.PlaylistRepository; import com.odeyalo.sonata.playlists.support.pagination.OffsetBasedPageRequest; -import com.odeyalo.sonata.suite.brokers.events.SonataEvent; import com.odeyalo.sonata.suite.brokers.events.playlist.gen.GeneratedPlaylistType; import com.odeyalo.sonata.suite.brokers.events.playlist.gen.PlaylistImagesGeneratedEvent; import com.odeyalo.sonata.suite.brokers.events.playlist.gen.payload.GeneratedTrack; @@ -18,18 +17,14 @@ import com.odeyalo.sonata.suite.brokers.events.playlist.gen.payload.PlaylistTracksGeneratedPayload; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.kafka.core.KafkaTemplate; -import org.springframework.test.context.ActiveProfiles; +import testing.core.AbstractIntegrationTest; import java.util.Comparator; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; -@SpringBootTest -@ActiveProfiles("test") -public final class PlaylistGenerationManagerTest { +public class PlaylistGenerationManagerTest extends AbstractIntegrationTest { @Autowired PlaylistGenerationManager testable; diff --git a/src/test/java/testing/core/AbstractIntegrationTest.java b/src/test/java/testing/core/AbstractIntegrationTest.java new file mode 100644 index 0000000..0c061e8 --- /dev/null +++ b/src/test/java/testing/core/AbstractIntegrationTest.java @@ -0,0 +1,38 @@ +package testing.core; + + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.TestInstance; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; + +@SpringBootTest +@ActiveProfiles("test") +public abstract class AbstractIntegrationTest { + + static PostgreSQLContainer postgres = new PostgreSQLContainer<>( + "postgres:15.2" + ); + + @BeforeAll + static void beforeAll() { + postgres.start(); + } + + @DynamicPropertySource + static void registerDynamicProperties(DynamicPropertyRegistry registry) { + registry.add("spring.r2dbc.url", () -> "r2dbc:postgresql://" + + postgres.getHost() + ":" + postgres.getFirstMappedPort() + + "/" + postgres.getDatabaseName()); + registry.add("spring.r2dbc.username", () -> postgres.getUsername()); + registry.add("spring.r2dbc.password", () -> postgres.getPassword()); + // flyway + registry.add("spring.flyway.enabled", () -> true); + registry.add("spring.flyway.user", () -> postgres.getUsername()); + registry.add("spring.flyway.password", () -> postgres.getPassword()); + registry.add("spring.flyway.url", () -> postgres.getJdbcUrl()); + } +} \ No newline at end of file