diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..38205c1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,3 @@ +FROM openjdk:17-alpine +COPY target/somafm-song-history-0.2.0-jar-with-dependencies.jar app.jar +ENTRYPOINT ["java","-jar","/app.jar"] diff --git a/README.org b/README.org index a668984..eb3ceea 100644 --- a/README.org +++ b/README.org @@ -1,19 +1,101 @@ #+title: =somafm-song-history= -Command line application that retrieves and prints SomaFM's song -history in the console. This is a first step towards building a real -database of broadcasts. +Application that retrieves and prints [[https://somafm.com/][SomaFM]]'s recently played songs +in the console, or save it to a database. + +Please support SomaFM's awesome work [[https://somafm.com/support/][here]]. + +* About +:PROPERTIES: +:CREATED: [2022-12-30 Fri 12:00] +:END: + +This project is developed for my personal use, but I'll be glad to +help if you encounter any [[https://github.com/alecigne/somafm-song-history/issues][issue]]. + +My goal is to build and browse a personal database of songs played by +SomaFM, and as an ambient fan, especially Drone Zone. + +As stated on [[https://somafm.com/linktous/api.html][this page]]: + +#+begin_quote +SomaFM API is no longer available to third parties +#+end_quote + +Thus this application works by parsing SomaFM's "Recently Played +Songs" page (example [[https://somafm.com/dronezone/songhistory.html][here]]). * Usage :PROPERTIES: :CREATED: [2022-11-02 Wed 19:00] :END: +** v0.3 (next) +:PROPERTIES: +:CREATED: [2022-12-30 Fri 11:50] +:END: + +Goals for v0.3: + +- Use a scheduler to retrieve history at regular intervals. History + cannot be displayed in the console anymore. +- Expose a REST API of recently played songs. + +** v0.2 (current) +:PROPERTIES: +:CREATED: [2022-12-30 Fri 11:49] +:END: + +1. You will need a PostgreSQL database running. This can be achieved + using Docker and the PostgreSQL official image: + + #+begin_src sh + docker run \ + --name postgres-db \ + -e POSTGRES_PASSWORD=mysecretpassword \ + -p 5432:5432 \ + -d postgres + #+end_src + + The default user is =postgres=. + +2. Either download the provided image (TODO) or build it: + + #+begin_src sh + docker image build -t somafm-song-history . + #+end_src + +3. Run the corresponding container with your credentials of choice: + + #+begin_src sh + docker run \ + -e DB_URL="jdbc:postgresql://localhost:5432/postgres" \ + -e DB_USER="postgres" \ + -e DB_PASSWORD="mysecretpassword" \ + --net=host \ + somafm-song-history "display" "Drone Zone" + #+end_src + +Two arguments must be provided: an /action/ (=display= or =save=) and +a /channel/ (e.g. =Groove Salad=). The channel name is its public +name, available on [[https://somafm.com/#alpha][this page]]. Without a channel, the program will +default to Drone Zone. + +** v0.1 +:PROPERTIES: +:CREATED: [2022-12-30 Fri 11:48] +:END: + +This version only displays the history in the console. + #+begin_src sh java -jar somafm-song-history-0.1.0.jar "Beat Blender" #+end_src -Without a channel, the program will default to Drone Zone. +(with Java 17) + +The channel name is the public name, available on [[https://somafm.com/#alpha][this page]]. Without a +channel, the program will default to Drone Zone. * Known bugs :PROPERTIES: diff --git a/pom.xml b/pom.xml index 12014ad..a4a12d4 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ net.lecigne somafm-song-history - 0.1.0 + 0.2.0 17 @@ -15,9 +15,39 @@ UTF-8 2.22.2 2.19.0 + 9.10.2 + 1.17.6 + + + com.zaxxer + HikariCP + 5.0.1 + + + slf4j-api + org.slf4j + + + + + org.flywaydb + flyway-core + ${flyway.version} + + + org.flywaydb + flyway-mysql + ${flyway.version} + + + org.postgresql + postgresql + 42.5.0 + + com.squareup.retrofit2 retrofit @@ -77,7 +107,7 @@ org.mockito mockito-core - 4.8.1 + 4.10.0 test @@ -92,6 +122,36 @@ 1.2.1 test + + com.h2database + h2 + 2.1.214 + test + + + org.testcontainers + testcontainers + ${testcontainers.version} + test + + + org.testcontainers + junit-jupiter + ${testcontainers.version} + test + + + org.testcontainers + postgresql + ${testcontainers.version} + test + + + io.github.hakky54 + logcaptor + 2.7.10 + test + @@ -100,10 +160,22 @@ org.apache.maven.plugins maven-surefire-plugin ${maven.testing.version} + + + + org.apache.logging.log4j:log4j-slf4j-impl + + maven-failsafe-plugin ${maven.testing.version} + + + + org.apache.logging.log4j:log4j-slf4j-impl + + diff --git a/src/main/java/net/lecigne/somafm/SomaFmSongHistory.java b/src/main/java/net/lecigne/somafm/SomaFmSongHistory.java index 7e621f2..698596e 100644 --- a/src/main/java/net/lecigne/somafm/SomaFmSongHistory.java +++ b/src/main/java/net/lecigne/somafm/SomaFmSongHistory.java @@ -1,15 +1,18 @@ package net.lecigne.somafm; -import static net.lecigne.somafm.config.Configuration.ROOT_CONFIG; -import static net.lecigne.somafm.model.Channel.DRONE_ZONE; +import static net.lecigne.somafm.config.SomaFmConfig.ROOT_CONFIG; import com.typesafe.config.Config; import com.typesafe.config.ConfigBeanFactory; import com.typesafe.config.ConfigFactory; import java.time.ZoneId; +import lombok.extern.slf4j.Slf4j; import net.lecigne.somafm.business.RecentBroadcastBusiness; -import net.lecigne.somafm.config.Configuration; +import net.lecigne.somafm.cli.CLI; +import net.lecigne.somafm.config.SomaFmConfig; +import org.flywaydb.core.Flyway; +@Slf4j public class SomaFmSongHistory { /** @@ -23,11 +26,21 @@ public class SomaFmSongHistory { public static final String BREAK_STATION_ID = "Break / Station ID"; public static void main(String[] args) { + // Load config Config config = ConfigFactory.load(); - Configuration configuration = ConfigBeanFactory.create(config.getConfig(ROOT_CONFIG), Configuration.class); - String defaultChannel = args.length != 0 ? args[0] : DRONE_ZONE.getPublicName(); - RecentBroadcastBusiness business = RecentBroadcastBusiness.init(configuration); - business.displayRecentBroadcasts(defaultChannel); + SomaFmConfig somaFmConfig = ConfigBeanFactory.create(config.getConfig(ROOT_CONFIG), SomaFmConfig.class); + + // Prepare database + Flyway.configure() + .dataSource(somaFmConfig.getDbUrl(), somaFmConfig.getDbUser(), somaFmConfig.getDbPassword()) + .load() + .migrate(); + + // Prepare business + RecentBroadcastBusiness business = RecentBroadcastBusiness.init(somaFmConfig); + + // Run + new CLI(business).run(args); } } diff --git a/src/main/java/net/lecigne/somafm/business/BusinessAction.java b/src/main/java/net/lecigne/somafm/business/BusinessAction.java new file mode 100644 index 0000000..6d87ec9 --- /dev/null +++ b/src/main/java/net/lecigne/somafm/business/BusinessAction.java @@ -0,0 +1,19 @@ +package net.lecigne.somafm.business; + +import java.util.Arrays; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum BusinessAction { + DISPLAY("display"), SAVE("save"); + + private final String actionName; + + public static BusinessAction getValue(String actionName) { + return Arrays.stream(BusinessAction.values()) + .filter(value -> value.actionName.equals(actionName)) + .findFirst() + .orElse(DISPLAY); + } + +} diff --git a/src/main/java/net/lecigne/somafm/business/RecentBroadcastBusiness.java b/src/main/java/net/lecigne/somafm/business/RecentBroadcastBusiness.java index 64dc673..950c559 100644 --- a/src/main/java/net/lecigne/somafm/business/RecentBroadcastBusiness.java +++ b/src/main/java/net/lecigne/somafm/business/RecentBroadcastBusiness.java @@ -1,10 +1,16 @@ package net.lecigne.somafm.business; +import static net.lecigne.somafm.business.BusinessAction.DISPLAY; +import static net.lecigne.somafm.business.BusinessAction.SAVE; + import java.io.IOException; import java.time.ZoneId; import java.util.Comparator; -import lombok.RequiredArgsConstructor; -import net.lecigne.somafm.config.Configuration; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; +import lombok.extern.slf4j.Slf4j; +import net.lecigne.somafm.config.SomaFmConfig; import net.lecigne.somafm.exception.SomaFmHtmlParsingException; import net.lecigne.somafm.mappers.DisplayedBroadcastMapper; import net.lecigne.somafm.model.Broadcast; @@ -12,28 +18,44 @@ import net.lecigne.somafm.repository.BroadcastRepository; import net.lecigne.somafm.repository.DefaultBroadcastRepository; -@RequiredArgsConstructor +@Slf4j public class RecentBroadcastBusiness { private final BroadcastRepository broadcastRepository; private final DisplayedBroadcastMapper displayedBroadcastMapper; + private final Map>> strategies; + + public RecentBroadcastBusiness( + BroadcastRepository broadcastRepository, + DisplayedBroadcastMapper displayedBroadcastMapper) { + this.broadcastRepository = broadcastRepository; + this.displayedBroadcastMapper = displayedBroadcastMapper; + this.strategies = Map.of(DISPLAY, displayRecentBroadcasts(), SAVE, saveRecentBroadcasts()); + } - @SuppressWarnings("java:S106") // command line application - println is ok - public void displayRecentBroadcasts(String publicName) { - Channel channel = Channel.getByPublicName(publicName); + public void handleRecentBroadcasts(BusinessAction action, Channel channel) { try { - broadcastRepository.getRecentBroadcasts(channel).stream() - .sorted(Comparator.comparing(Broadcast::getTime).reversed()) - .map(displayedBroadcastMapper::map) - .forEach(System.out::println); + Set recentBroadcasts = broadcastRepository.getRecentBroadcasts(channel); + strategies.get(action).accept(recentBroadcasts); } catch (IOException e) { - System.out.println("Unable to get recent broadcast from SomaFM at this time. Please try again later."); + log.error("Unable to get recent broadcasts from SomaFM at this time. Please try again later.", e); } catch (SomaFmHtmlParsingException e) { - System.out.println(e.getMessage()); + log.error("Unable to parse SomaFM recent broadcasts page.", e); } } - public static RecentBroadcastBusiness init(Configuration config) { + private Consumer> saveRecentBroadcasts() { + return broadcastRepository::updateBroadcasts; + } + + private Consumer> displayRecentBroadcasts() { + return broadcasts -> broadcasts.stream() + .sorted(Comparator.comparing(Broadcast::getTime).reversed()) + .map(displayedBroadcastMapper::map) + .forEach(System.out::println); + } + + public static RecentBroadcastBusiness init(SomaFmConfig config) { return new RecentBroadcastBusiness( DefaultBroadcastRepository.init(config), new DisplayedBroadcastMapper(ZoneId.systemDefault()) diff --git a/src/main/java/net/lecigne/somafm/cli/CLI.java b/src/main/java/net/lecigne/somafm/cli/CLI.java new file mode 100644 index 0000000..384c4d3 --- /dev/null +++ b/src/main/java/net/lecigne/somafm/cli/CLI.java @@ -0,0 +1,39 @@ +package net.lecigne.somafm.cli; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.lecigne.somafm.business.BusinessAction; +import net.lecigne.somafm.business.RecentBroadcastBusiness; +import net.lecigne.somafm.exception.UnknownChannelException; +import net.lecigne.somafm.model.Channel; + +@Slf4j +@RequiredArgsConstructor +public class CLI { + + private final RecentBroadcastBusiness business; + + public void run(String[] args) { + try { + checkArgs(args); + BusinessAction action = BusinessAction.getValue(args[0]); + Channel channel = Channel + .getByPublicName(args[1]) + .orElseThrow(() -> new UnknownChannelException(args[1])); + business.handleRecentBroadcasts(action, channel); + } catch (UnknownChannelException e) { + log.error("Unknown channel: {}", args[1]); + } catch (IllegalArgumentException e) { + log.error("You must enter two arguments - action and channel."); + } catch (Exception e) { + log.error("Unexpected error.", e); + } + } + + private static void checkArgs(String[] args) { + if (args.length != 2) { + throw new IllegalArgumentException(); + } + } + +} diff --git a/src/main/java/net/lecigne/somafm/client/HtmlBroadcastsClient.java b/src/main/java/net/lecigne/somafm/client/HtmlBroadcastsClient.java index ca8a584..5ff6e83 100644 --- a/src/main/java/net/lecigne/somafm/client/HtmlBroadcastsClient.java +++ b/src/main/java/net/lecigne/somafm/client/HtmlBroadcastsClient.java @@ -1,6 +1,6 @@ package net.lecigne.somafm.client; -import net.lecigne.somafm.config.Configuration; +import net.lecigne.somafm.config.SomaFmConfig; import okhttp3.OkHttpClient; import okhttp3.ResponseBody; import retrofit2.Call; @@ -15,7 +15,7 @@ public interface HtmlBroadcastsClient { @GET("/recent/{channel}.html") Call getHtml(@Path("channel") String channel); - static HtmlBroadcastsClient create(Configuration config) { + static HtmlBroadcastsClient create(SomaFmConfig config) { return new Retrofit.Builder() .baseUrl(config.getSomaFmBaseUrl()) .client(new OkHttpClient.Builder() diff --git a/src/main/java/net/lecigne/somafm/client/HtmlBroadcastsParser.java b/src/main/java/net/lecigne/somafm/client/HtmlBroadcastsParser.java index 3de48c1..fa26eca 100644 --- a/src/main/java/net/lecigne/somafm/client/HtmlBroadcastsParser.java +++ b/src/main/java/net/lecigne/somafm/client/HtmlBroadcastsParser.java @@ -37,7 +37,9 @@ private BroadcastDto parseRow(Element row) { if (BREAK_STATION_ID.equals(secondField)) { return BroadcastDto.builder() .time(localTime) + .artist("n/a") .title(secondField) + .album("n/a") .build(); } else { var title = columns.get(2).text(); diff --git a/src/main/java/net/lecigne/somafm/client/RecentBroadcastsClient.java b/src/main/java/net/lecigne/somafm/client/RecentBroadcastsClient.java index ebe8801..b4e3da5 100644 --- a/src/main/java/net/lecigne/somafm/client/RecentBroadcastsClient.java +++ b/src/main/java/net/lecigne/somafm/client/RecentBroadcastsClient.java @@ -5,7 +5,7 @@ import lombok.AllArgsConstructor; import net.lecigne.somafm.client.dto.BroadcastDto; import net.lecigne.somafm.client.dto.RecentBroadcastsDto; -import net.lecigne.somafm.config.Configuration; +import net.lecigne.somafm.config.SomaFmConfig; import net.lecigne.somafm.model.Channel; import okhttp3.ResponseBody; import retrofit2.Response; @@ -23,12 +23,12 @@ public RecentBroadcastsDto get(Channel channel) throws IOException { Response response = htmlBroadcastsClient.getHtml(channel.getInternalName()).execute(); List recentBroadcasts = htmlBroadcastsParser.parse(response.body().string()); return RecentBroadcastsDto.builder() - .channel(channel.getInternalName()) + .channel(channel) .recentBroadcasts(recentBroadcasts) .build(); } - public static RecentBroadcastsClient init(Configuration config) { + public static RecentBroadcastsClient init(SomaFmConfig config) { return new RecentBroadcastsClient(HtmlBroadcastsClient.create(config), new HtmlBroadcastsParser()); } diff --git a/src/main/java/net/lecigne/somafm/client/dto/RecentBroadcastsDto.java b/src/main/java/net/lecigne/somafm/client/dto/RecentBroadcastsDto.java index 51d3d32..d6ea10d 100644 --- a/src/main/java/net/lecigne/somafm/client/dto/RecentBroadcastsDto.java +++ b/src/main/java/net/lecigne/somafm/client/dto/RecentBroadcastsDto.java @@ -3,10 +3,11 @@ import java.util.List; import lombok.Builder; import lombok.Value; +import net.lecigne.somafm.model.Channel; @Builder @Value public class RecentBroadcastsDto { - String channel; + Channel channel; List recentBroadcasts; } diff --git a/src/main/java/net/lecigne/somafm/config/Configuration.java b/src/main/java/net/lecigne/somafm/config/SomaFmConfig.java similarity index 71% rename from src/main/java/net/lecigne/somafm/config/Configuration.java rename to src/main/java/net/lecigne/somafm/config/SomaFmConfig.java index 76a64d2..12e9151 100644 --- a/src/main/java/net/lecigne/somafm/config/Configuration.java +++ b/src/main/java/net/lecigne/somafm/config/SomaFmConfig.java @@ -7,8 +7,11 @@ @NoArgsConstructor @Getter @Setter -public class Configuration { +public class SomaFmConfig { public static final String ROOT_CONFIG = "config"; private String somaFmBaseUrl; private String userAgent; + private String dbUrl; + private String dbUser; + private String dbPassword; } diff --git a/src/main/java/net/lecigne/somafm/exception/UnknownChannelException.java b/src/main/java/net/lecigne/somafm/exception/UnknownChannelException.java new file mode 100644 index 0000000..326a708 --- /dev/null +++ b/src/main/java/net/lecigne/somafm/exception/UnknownChannelException.java @@ -0,0 +1,9 @@ +package net.lecigne.somafm.exception; + +public class UnknownChannelException extends RuntimeException { + + public UnknownChannelException(String channel) { + super("Unknown channel: " + channel); + } + +} diff --git a/src/main/java/net/lecigne/somafm/mappers/BroadcastMapper.java b/src/main/java/net/lecigne/somafm/mappers/BroadcastMapper.java index fba549f..730f4c1 100644 --- a/src/main/java/net/lecigne/somafm/mappers/BroadcastMapper.java +++ b/src/main/java/net/lecigne/somafm/mappers/BroadcastMapper.java @@ -7,23 +7,23 @@ import java.util.Set; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import net.lecigne.somafm.client.dto.RecentBroadcastsDto; import net.lecigne.somafm.model.Broadcast; -import net.lecigne.somafm.model.Channel; import net.lecigne.somafm.model.Song; import net.lecigne.somafm.utils.TimeUtils; @RequiredArgsConstructor +@Slf4j public class BroadcastMapper { private final Clock clock; public Set map(RecentBroadcastsDto recentBroadcastsDto) { - Channel channel = Channel.getByInternalName(recentBroadcastsDto.getChannel()); return recentBroadcastsDto.getRecentBroadcasts().stream() .map(dto -> Broadcast.builder() .time(TimeUtils.localBroadcastTimeToInstant(dto.getTime(), Instant.now(clock), BROADCAST_LOCATION)) - .channel(channel) + .channel(recentBroadcastsDto.getChannel()) .song(Song.builder() .artist(dto.getArtist()) .title(dto.getTitle()) diff --git a/src/main/java/net/lecigne/somafm/model/Channel.java b/src/main/java/net/lecigne/somafm/model/Channel.java index a90ecc8..e8fc15c 100644 --- a/src/main/java/net/lecigne/somafm/model/Channel.java +++ b/src/main/java/net/lecigne/somafm/model/Channel.java @@ -1,6 +1,7 @@ package net.lecigne.somafm.model; import java.util.Arrays; +import java.util.Optional; import java.util.function.Predicate; import lombok.Getter; @@ -56,19 +57,18 @@ public enum Channel { this.publicName = publicName; } - public static Channel getByInternalName(String internalName) { + public static Optional getByInternalName(String internalName) { return getBy(channel -> channel.internalName.equals(internalName)); } - public static Channel getByPublicName(String publicName) { + public static Optional getByPublicName(String publicName) { return getBy(channel -> channel.publicName.equals(publicName)); } - private static Channel getBy(Predicate predicate) { + private static Optional getBy(Predicate predicate) { return Arrays.stream(Channel.values()) .filter(predicate) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("Unknown channel!")); + .findFirst(); } } diff --git a/src/main/java/net/lecigne/somafm/repository/BroadcastRepository.java b/src/main/java/net/lecigne/somafm/repository/BroadcastRepository.java index d625423..57fdec1 100644 --- a/src/main/java/net/lecigne/somafm/repository/BroadcastRepository.java +++ b/src/main/java/net/lecigne/somafm/repository/BroadcastRepository.java @@ -7,4 +7,5 @@ public interface BroadcastRepository { Set getRecentBroadcasts(Channel channel) throws IOException; + void updateBroadcasts(Set broadcasts); } diff --git a/src/main/java/net/lecigne/somafm/repository/DefaultBroadcastRepository.java b/src/main/java/net/lecigne/somafm/repository/DefaultBroadcastRepository.java index 780b134..de5ebcd 100644 --- a/src/main/java/net/lecigne/somafm/repository/DefaultBroadcastRepository.java +++ b/src/main/java/net/lecigne/somafm/repository/DefaultBroadcastRepository.java @@ -1,14 +1,20 @@ package net.lecigne.somafm.repository; +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; import java.io.IOException; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Timestamp; import java.time.Clock; import java.util.Set; +import javax.sql.DataSource; import lombok.AllArgsConstructor; -import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import net.lecigne.somafm.client.RecentBroadcastsClient; import net.lecigne.somafm.client.dto.RecentBroadcastsDto; -import net.lecigne.somafm.config.Configuration; +import net.lecigne.somafm.config.SomaFmConfig; import net.lecigne.somafm.mappers.BroadcastMapper; import net.lecigne.somafm.model.Broadcast; import net.lecigne.somafm.model.Channel; @@ -22,6 +28,7 @@ public class DefaultBroadcastRepository implements BroadcastRepository { private RecentBroadcastsClient recentBroadcastsClient; private BroadcastMapper broadcastMapper; + private final DataSource dataSource; @Override public Set getRecentBroadcasts(Channel channel) throws IOException { @@ -30,10 +37,57 @@ public Set getRecentBroadcasts(Channel channel) throws IOException { return broadcastMapper.map(recentBroadcastsDto); } - public static BroadcastRepository init(Configuration config) { + /** + * Update broadcasts with new data from SomaFM. + *

+ * The SQL code inserts a song only if it doesn't exist, but always return its ID. This ID is then referenced in the + * broadcasts table. + */ + @Override + public void updateBroadcasts(Set broadcasts) { + var sql = """ + WITH upsert_song AS( + INSERT INTO songs (artist, title, album) + VALUES (?, ?, ?) + ON CONFLICT DO NOTHING + RETURNING id AS new_song_id + ), + select_song_id AS( + SELECT new_song_id FROM upsert_song + UNION + SELECT id FROM songs WHERE artist=? AND title=? AND album=? + ) + INSERT INTO broadcasts (utc_time, channel, song_id) + SELECT ?, ?, new_song_id FROM select_song_id + ON CONFLICT DO NOTHING;"""; + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + for (Broadcast broadcast : broadcasts) { + statement.setString(1, broadcast.getSong().getArtist()); + statement.setString(2, broadcast.getSong().getTitle()); + statement.setString(3, broadcast.getSong().getAlbum()); + statement.setString(4, broadcast.getSong().getArtist()); + statement.setString(5, broadcast.getSong().getTitle()); + statement.setString(6, broadcast.getSong().getAlbum()); + statement.setTimestamp(7, Timestamp.from(broadcast.getTime())); + statement.setString(8, broadcast.getChannel().name()); + statement.addBatch(); + } + statement.executeBatch(); + } catch (SQLException e) { + log.error("Error", e); + } + } + + public static BroadcastRepository init(SomaFmConfig config) { RecentBroadcastsClient client = RecentBroadcastsClient.init(config); - BroadcastMapper mapper = new BroadcastMapper(Clock.systemDefaultZone()); - return new DefaultBroadcastRepository(client, mapper); + var mapper = new BroadcastMapper(Clock.systemDefaultZone()); + var hikariConfig = new HikariConfig(); + hikariConfig.setJdbcUrl(config.getDbUrl()); + hikariConfig.setUsername(config.getDbUser()); + hikariConfig.setPassword(config.getDbPassword()); + var hikariDataSource = new HikariDataSource(hikariConfig); + return new DefaultBroadcastRepository(client, mapper, hikariDataSource); } } diff --git a/src/main/resources/application.conf b/src/main/resources/application.conf index 5fc6869..bdaa182 100644 --- a/src/main/resources/application.conf +++ b/src/main/resources/application.conf @@ -1,4 +1,7 @@ config { somaFmBaseUrl = "https://somafm.com/" userAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.131 Safari/537.36" + dbUrl = ${DB_URL} + dbUser = ${DB_USER} + dbPassword = ${DB_PASSWORD} } diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql new file mode 100644 index 0000000..d6aeca6 --- /dev/null +++ b/src/main/resources/db/migration/V1__init.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS songs +( + id SERIAL UNIQUE, + artist VARCHAR(255), + title VARCHAR(255), + album VARCHAR(255), + PRIMARY KEY (artist, title, album) +); + +CREATE TABLE IF NOT EXISTS broadcasts +( + utc_time TIMESTAMPTZ, + channel VARCHAR(255), + song_id INT REFERENCES songs(id), + PRIMARY KEY (utc_time, channel) +); diff --git a/src/test/java/net/lecigne/somafm/business/RecentBroadcastBusinessTest.java b/src/test/java/net/lecigne/somafm/business/RecentBroadcastBusinessTest.java index b7903fd..5f9714d 100644 --- a/src/test/java/net/lecigne/somafm/business/RecentBroadcastBusinessTest.java +++ b/src/test/java/net/lecigne/somafm/business/RecentBroadcastBusinessTest.java @@ -1,6 +1,7 @@ package net.lecigne.somafm.business; import static com.github.stefanbirkner.systemlambda.SystemLambda.tapSystemOut; +import static net.lecigne.somafm.business.BusinessAction.DISPLAY; import static net.lecigne.somafm.fixtures.TestFixtures.breakSongFixture; import static net.lecigne.somafm.fixtures.TestFixtures.dirkSerriesSongFixture; import static net.lecigne.somafm.fixtures.TestFixtures.igneousFlameSongFixture; @@ -19,7 +20,9 @@ import net.lecigne.somafm.model.Broadcast; import net.lecigne.somafm.repository.BroadcastRepository; import net.lecigne.somafm.repository.DefaultBroadcastRepository; +import nl.altindag.log.LogCaptor; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.mockito.Mockito; @@ -28,69 +31,78 @@ class RecentBroadcastBusinessTest { private final DisplayedBroadcastMapper mapper = new DisplayedBroadcastMapper(ZoneId.of("Europe/Paris")); - @Test - void should_display_a_list_of_recent_broadcasts() throws Exception { - // Given - BroadcastRepository repository = Mockito.mock(DefaultBroadcastRepository.class); - given(repository.getRecentBroadcasts(any())).willReturn(Set.of( - Broadcast.builder() - .time(Instant.parse("2021-01-01T13:00:00.00Z")) - .channel(DRONE_ZONE) - .song(dirkSerriesSongFixture()) - .build(), - Broadcast.builder() - .time(Instant.parse("2021-01-01T13:15:00.00Z")) - .channel(DRONE_ZONE) - .song(igneousFlameSongFixture()) - .build(), - Broadcast.builder() - .time(Instant.parse("2021-01-01T13:20:00.00Z")) - .channel(DRONE_ZONE) - .song(breakSongFixture()) - .build())); - var business = new RecentBroadcastBusiness(repository, mapper); - var expectedOutput = """ - [2021-01-01 14:20:00 @ Drone Zone] Break / Station ID - [2021-01-01 14:15:00 @ Drone Zone] Igneous Flame - Incandescent Arc - [2021-01-01 14:00:00 @ Drone Zone] Dirk Serries' Microphonics - VI""" - .replaceAll("[\\r\\n]", ""); - - // When - Statement statement = () -> business.displayRecentBroadcasts(DRONE_ZONE.getPublicName()); - String actualOutput = tapSystemOut(statement).replaceAll("[\\r\\n]", ""); - - // Then - assertThat(actualOutput).isEqualTo(expectedOutput); - } + @Nested + class when_displaying_broadcasts { - @Test - void should_display_an_informative_message_if_broadcast_retrieval_fails() throws Exception { - // Given - BroadcastRepository repository = Mockito.mock(DefaultBroadcastRepository.class); - given(repository.getRecentBroadcasts(any())).willThrow(IOException.class); - var business = new RecentBroadcastBusiness(repository, mapper); - var expectedOutput = "Unable to get recent broadcast from SomaFM at this time. Please try again later."; + @Test + void should_display_a_list_of_recent_broadcasts() throws Exception { + // Given + BroadcastRepository repository = Mockito.mock(DefaultBroadcastRepository.class); + given(repository.getRecentBroadcasts(any())).willReturn(Set.of( + Broadcast.builder() + .time(Instant.parse("2021-01-01T13:00:00.00Z")) + .channel(DRONE_ZONE) + .song(dirkSerriesSongFixture()) + .build(), + Broadcast.builder() + .time(Instant.parse("2021-01-01T13:15:00.00Z")) + .channel(DRONE_ZONE) + .song(igneousFlameSongFixture()) + .build(), + Broadcast.builder() + .time(Instant.parse("2021-01-01T13:20:00.00Z")) + .channel(DRONE_ZONE) + .song(breakSongFixture()) + .build())); + var business = new RecentBroadcastBusiness(repository, mapper); + var expected = """ + [2021-01-01 14:20:00 @ Drone Zone] Break / Station ID + [2021-01-01 14:15:00 @ Drone Zone] Igneous Flame - Incandescent Arc + [2021-01-01 14:00:00 @ Drone Zone] Dirk Serries' Microphonics - VI""" + .replaceAll("[\\r\\n]", ""); - // When - String actualOutput = tapSystemOut(() -> business.displayRecentBroadcasts(DRONE_ZONE.getPublicName())).trim(); + // When + Statement statement = () -> business.handleRecentBroadcasts(DISPLAY, DRONE_ZONE); + String actual = tapSystemOut(statement).replaceAll("[\\r\\n]", ""); - // Then - assertThat(actualOutput).isEqualTo(expectedOutput); - } + // Then + assertThat(actual).isEqualTo(expected); + } + + @Test + void should_display_an_informative_message_if_broadcast_retrieval_fails() throws Exception { + // Given + BroadcastRepository repository = Mockito.mock(DefaultBroadcastRepository.class); + given(repository.getRecentBroadcasts(any())).willThrow(IOException.class); + var business = new RecentBroadcastBusiness(repository, mapper); + var expected = "Unable to get recent broadcasts from SomaFM at this time. Please try again later."; + + try (LogCaptor logCaptor = LogCaptor.forClass(RecentBroadcastBusiness.class)) { + // When + business.handleRecentBroadcasts(DISPLAY, DRONE_ZONE); + + // Then + assertThat(logCaptor.getErrorLogs()).containsExactly(expected); + } + } + + @Test + void should_display_an_informative_message_if_parsing_fails() throws Exception { + // Given + BroadcastRepository repository = Mockito.mock(DefaultBroadcastRepository.class); + var expected = "Unable to parse SomaFM recent broadcasts page."; + given(repository.getRecentBroadcasts(any())).willThrow(new SomaFmHtmlParsingException(expected)); + var business = new RecentBroadcastBusiness(repository, mapper); - @Test - void should_display_an_informative_message_if_parsing_fails() throws Exception { - // Given - BroadcastRepository repository = Mockito.mock(DefaultBroadcastRepository.class); - var error = "Parsing error"; - given(repository.getRecentBroadcasts(any())).willThrow(new SomaFmHtmlParsingException(error)); - var business = new RecentBroadcastBusiness(repository, mapper); + try (LogCaptor logCaptor = LogCaptor.forClass(RecentBroadcastBusiness.class)) { + // When + business.handleRecentBroadcasts(DISPLAY, DRONE_ZONE); - // When - String actualOutput = tapSystemOut(() -> business.displayRecentBroadcasts(DRONE_ZONE.getPublicName())).trim(); + // Then + assertThat(logCaptor.getErrorLogs()).containsExactly(expected); + } + } - // Then - assertThat(actualOutput).isEqualTo(error); } } diff --git a/src/test/java/net/lecigne/somafm/cli/CLITest.java b/src/test/java/net/lecigne/somafm/cli/CLITest.java new file mode 100644 index 0000000..e365adf --- /dev/null +++ b/src/test/java/net/lecigne/somafm/cli/CLITest.java @@ -0,0 +1,73 @@ +package net.lecigne.somafm.cli; + +import static net.lecigne.somafm.business.BusinessAction.DISPLAY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; + +import net.lecigne.somafm.business.BusinessAction; +import net.lecigne.somafm.business.RecentBroadcastBusiness; +import nl.altindag.log.LogCaptor; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +@DisplayName("The CLI") +class CLITest { + + private RecentBroadcastBusiness business; + private CLI cli; + + @BeforeEach + void setUp() { + business = Mockito.mock(RecentBroadcastBusiness.class); + cli = new CLI(business); + } + + @Test + void should_log_error_if_wrong_number_of_args() { + // Given + var args = new String[]{"one arg"}; + var expected = "You must enter two arguments - action and channel."; + + try (LogCaptor logCaptor = LogCaptor.forClass(CLI.class)) { + // When + cli.run(args); + + // Then + assertThat(logCaptor.getErrorLogs()).contains(expected); + } + } + + @Test + void should_use_display_action_if_wrong_action() { + // Given + var args = new String[]{"foobar", "Drone Zone"}; + ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(BusinessAction.class); + + // When + cli.run(args); + + // Then + Mockito.verify(business).handleRecentBroadcasts(argumentCaptor.capture(), any()); + BusinessAction action = argumentCaptor.getValue(); + assertThat(action).isEqualTo(DISPLAY); + } + + @Test + void should_log_if_unknown_channel() { + // Given + var args = new String[]{"display", "Foobar FM"}; + var expected = "Unknown channel: Foobar FM"; + + try (LogCaptor logCaptor = LogCaptor.forClass(CLI.class)) { + // When + cli.run(args); + + // Then + assertThat(logCaptor.getErrorLogs()).contains(expected); + } + } + +} diff --git a/src/test/java/net/lecigne/somafm/fixtures/TestRepository.java b/src/test/java/net/lecigne/somafm/fixtures/TestRepository.java new file mode 100644 index 0000000..d74485d --- /dev/null +++ b/src/test/java/net/lecigne/somafm/fixtures/TestRepository.java @@ -0,0 +1,84 @@ +package net.lecigne.somafm.fixtures; + +import java.io.IOException; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.List; +import javax.sql.DataSource; +import lombok.RequiredArgsConstructor; +import net.lecigne.somafm.model.Broadcast; +import net.lecigne.somafm.model.Channel; +import net.lecigne.somafm.model.Song; + +@RequiredArgsConstructor +public class TestRepository { + + private final DataSource testDataSource; + + public List readAllBroadcasts() throws IOException { + var sql = """ + SELECT utc_time, channel, artist, title, album FROM broadcasts + JOIN songs on broadcasts.song_id = songs.id + ORDER BY utc_time DESC;"""; + List results = new ArrayList<>(); + try (Connection connection = testDataSource.getConnection(); + PreparedStatement preparedStatement = connection.prepareStatement(sql); + ResultSet resultSet = preparedStatement.executeQuery()) { + while (resultSet.next()) { + var time = resultSet.getTimestamp(1); + var channel = resultSet.getString(2); + var artist = resultSet.getString(3); + var title = resultSet.getString(4); + var album = resultSet.getString(5); + var broadcast = Broadcast.builder() + .time(time.toInstant()) + .channel(Channel.valueOf(channel)) + .song(Song.builder() + .artist(artist) + .title(title) + .album(album) + .build()).build(); + results.add(broadcast); + } + } catch (Exception e) { + throw new IOException(e); + } + return results; + } + + public List readAllSongs() throws IOException { + var sql = "SELECT artist, title, album FROM songs"; + List results = new ArrayList<>(); + try (Connection connection = testDataSource.getConnection(); + PreparedStatement preparedStatement = connection.prepareStatement(sql); + ResultSet resultSet = preparedStatement.executeQuery()) { + while (resultSet.next()) { + var artist = resultSet.getString(1); + var title = resultSet.getString(2); + var album = resultSet.getString(3); + var song = Song.builder() + .artist(artist) + .title(title) + .album(album) + .build(); + results.add(song); + } + } catch (Exception e) { + throw new IOException(e); + } + return results; + } + + public void deleteAllData() throws IOException { + var sql = "TRUNCATE TABLE songs, broadcasts"; + try (Connection connection = testDataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.executeUpdate(); + } catch (Exception e) { + throw new IOException(e); + } + } + +} diff --git a/src/test/java/net/lecigne/somafm/integration/SomaFmSongHistoryIT.java b/src/test/java/net/lecigne/somafm/integration/SomaFmSongHistoryIT.java new file mode 100644 index 0000000..e961b31 --- /dev/null +++ b/src/test/java/net/lecigne/somafm/integration/SomaFmSongHistoryIT.java @@ -0,0 +1,125 @@ +package net.lecigne.somafm.integration; + +import static net.lecigne.somafm.SomaFmSongHistory.BROADCAST_LOCATION; +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.io.Resources; +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import java.io.IOException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import java.util.List; +import net.lecigne.somafm.business.RecentBroadcastBusiness; +import net.lecigne.somafm.cli.CLI; +import net.lecigne.somafm.client.HtmlBroadcastsClient; +import net.lecigne.somafm.client.HtmlBroadcastsParser; +import net.lecigne.somafm.client.RecentBroadcastsClient; +import net.lecigne.somafm.config.SomaFmConfig; +import net.lecigne.somafm.fixtures.TestRepository; +import net.lecigne.somafm.mappers.BroadcastMapper; +import net.lecigne.somafm.mappers.DisplayedBroadcastMapper; +import net.lecigne.somafm.model.Broadcast; +import net.lecigne.somafm.model.Song; +import net.lecigne.somafm.repository.BroadcastRepository; +import net.lecigne.somafm.repository.DefaultBroadcastRepository; +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.flywaydb.core.Flyway; +import org.junit.Rule; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.PostgreSQLContainer; + +// TODO [persist-recent-broadcasts] Mutualize resources between this test and DefaultBroadcastRepositoryIT +@DisplayName("The application") +public class SomaFmSongHistoryIT { + + static MockWebServer mockWebServer; + private static CLI CLI; + private static TestRepository testRepository; + + @Rule + public static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres"); + + @BeforeAll + static void beforeAll() throws IOException { + // MockWebServer + mockWebServer = new MockWebServer(); + mockWebServer.start(); + mockWebServer.setDispatcher(getDispatcher()); + + // Persistence + postgres.start(); + var hikariConfig = new HikariConfig(); + hikariConfig.setJdbcUrl(postgres.getJdbcUrl()); + hikariConfig.setUsername(postgres.getUsername()); + hikariConfig.setPassword(postgres.getPassword()); + var hikariDataSource = new HikariDataSource(hikariConfig); + + Flyway.configure() + .dataSource(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword()) + .load() + .migrate(); + + // Application + SomaFmConfig configuration = new SomaFmConfig(); + configuration.setSomaFmBaseUrl(mockWebServer.url("/").toString()); + configuration.setUserAgent("UA"); + HtmlBroadcastsClient htmlClient = HtmlBroadcastsClient.create(configuration); + var recentBroadcastsClient = new RecentBroadcastsClient(htmlClient, new HtmlBroadcastsParser()); + Clock clock = Clock.fixed(Instant.parse("2021-01-01T13:00:00.00Z"), ZoneId.of("Europe/Paris")); + BroadcastRepository repository = new DefaultBroadcastRepository(recentBroadcastsClient, new BroadcastMapper(clock), + hikariDataSource); + var business = new RecentBroadcastBusiness(repository, new DisplayedBroadcastMapper(BROADCAST_LOCATION)); + testRepository = new TestRepository(hikariDataSource); + CLI = new CLI(business); + } + + @AfterEach + void tearDown() throws IOException { + testRepository.deleteAllData(); + } + + @AfterAll + static void afterAll() throws IOException { + mockWebServer.shutdown(); + } + + @Test + void should_get_recent_broadcasts_and_persist_them_with_no_song_duplicates() throws IOException { + // Given + String[] args = {"save", "Drone Zone"}; + + // When + CLI.run(args); + + // Then + List broadcasts = testRepository.readAllBroadcasts(); + List songs = testRepository.readAllSongs(); + assertThat(broadcasts).hasSize(20); + assertThat(songs).hasSize(19); + } + + private static Dispatcher getDispatcher() throws IOException { + URL url = Resources.getResource("data/dronezone_with_one_duplicate.html"); + String html = Resources.toString(url, StandardCharsets.UTF_8); + return new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest recordedRequest) { + return new MockResponse() + .setResponseCode(200) + .setBody(html); + } + }; + } + +} diff --git a/src/test/java/net/lecigne/somafm/repository/DefaultBroadcastRepositoryIT.java b/src/test/java/net/lecigne/somafm/repository/DefaultBroadcastRepositoryIT.java index db0d78d..af25eb6 100644 --- a/src/test/java/net/lecigne/somafm/repository/DefaultBroadcastRepositoryIT.java +++ b/src/test/java/net/lecigne/somafm/repository/DefaultBroadcastRepositoryIT.java @@ -1,10 +1,13 @@ package net.lecigne.somafm.repository; import static net.lecigne.somafm.fixtures.TestFixtures.dirkSerriesSongFixture; +import static net.lecigne.somafm.fixtures.TestFixtures.igneousFlameSongFixture; import static net.lecigne.somafm.model.Channel.DRONE_ZONE; import static org.assertj.core.api.Assertions.assertThat; import com.google.common.io.Resources; +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; import java.io.IOException; import java.net.URL; import java.nio.charset.StandardCharsets; @@ -12,40 +15,75 @@ import java.time.Instant; import java.time.ZoneId; import java.util.Comparator; +import java.util.List; import java.util.Set; import net.lecigne.somafm.client.HtmlBroadcastsClient; import net.lecigne.somafm.client.HtmlBroadcastsParser; import net.lecigne.somafm.client.RecentBroadcastsClient; -import net.lecigne.somafm.config.Configuration; +import net.lecigne.somafm.config.SomaFmConfig; +import net.lecigne.somafm.fixtures.TestRepository; import net.lecigne.somafm.mappers.BroadcastMapper; import net.lecigne.somafm.model.Broadcast; +import net.lecigne.somafm.model.Song; import okhttp3.mockwebserver.Dispatcher; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; import okhttp3.mockwebserver.RecordedRequest; +import org.flywaydb.core.Flyway; +import org.junit.Rule; import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Testcontainers; @DisplayName("The default broadcast repository") +@Testcontainers class DefaultBroadcastRepositoryIT { static MockWebServer mockWebServer; private static BroadcastRepository repository; + private static TestRepository testRepository; + + @Rule + public static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres"); @BeforeAll static void beforeAll() throws IOException { + // MockWebServer mockWebServer = new MockWebServer(); mockWebServer.start(); mockWebServer.setDispatcher(getDispatcher()); - Configuration configuration = new Configuration(); + + // Persistence + postgres.start(); + var hikariConfig = new HikariConfig(); + hikariConfig.setJdbcUrl(postgres.getJdbcUrl()); + hikariConfig.setUsername(postgres.getUsername()); + hikariConfig.setPassword(postgres.getPassword()); + var hikariDataSource = new HikariDataSource(hikariConfig); + + Flyway.configure() + .dataSource(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword()) + .load() + .migrate(); + + // Application + SomaFmConfig configuration = new SomaFmConfig(); configuration.setSomaFmBaseUrl(mockWebServer.url("/").toString()); configuration.setUserAgent("UA"); HtmlBroadcastsClient htmlClient = HtmlBroadcastsClient.create(configuration); var recentBroadcastsClient = new RecentBroadcastsClient(htmlClient, new HtmlBroadcastsParser()); Clock clock = Clock.fixed(Instant.parse("2021-01-01T13:00:00.00Z"), ZoneId.of("Europe/Paris")); - repository = new DefaultBroadcastRepository(recentBroadcastsClient, new BroadcastMapper(clock)); + repository = new DefaultBroadcastRepository(recentBroadcastsClient, new BroadcastMapper(clock), hikariDataSource); + testRepository = new TestRepository(hikariDataSource); + } + + @AfterEach + void tearDown() throws IOException { + testRepository.deleteAllData(); } @AfterAll @@ -74,6 +112,87 @@ void should_get_most_recent_broadcasts() throws IOException { assertThat(mostRecentBroadcast).usingRecursiveComparison().isEqualTo(expectedMostRecentBroadcast); } + @Test + void should_persist_broadcasts() throws IOException { + // Given + Broadcast broadcast1 = Broadcast.builder() + .time(Instant.parse("2021-01-01T11:36:43.123Z")) + .channel(DRONE_ZONE) + .song(dirkSerriesSongFixture()) + .build(); + Broadcast broadcast2 = Broadcast.builder() + .time(Instant.parse("2021-01-01T11:45:37.967Z")) + .channel(DRONE_ZONE) + .song(igneousFlameSongFixture()) + .build(); + + // When + repository.updateBroadcasts(Set.of(broadcast1, broadcast2)); + + // Then + List broadcasts = testRepository.readAllBroadcasts(); + assertThat(broadcasts) + .hasSize(2) + .usingRecursiveFieldByFieldElementComparator() + .containsExactlyInAnyOrder(broadcast1, broadcast2); + } + + @Test + void should_not_insert_same_broadcast_twice_and_ignore_failure() throws IOException { + // Given + Broadcast broadcast1 = Broadcast.builder() + .time(Instant.parse("2021-01-01T11:36:43.123Z")) + .channel(DRONE_ZONE) + .song(dirkSerriesSongFixture()) + .build(); + Broadcast broadcast2 = Broadcast.builder() + .time(Instant.parse("2021-01-01T11:36:43.123Z")) + .channel(DRONE_ZONE) + .song(dirkSerriesSongFixture()) + .build(); + + // When + repository.updateBroadcasts(Set.of(broadcast1, broadcast2)); + + // Then + List broadcasts = testRepository.readAllBroadcasts(); + assertThat(broadcasts) + .hasSize(1) + .usingRecursiveFieldByFieldElementComparator() + .containsExactlyInAnyOrder(broadcast1); + } + + @Test + void should_not_insert_same_song_twice() throws IOException { + // Given - 2 broadcasts of the same song + Broadcast broadcast1 = Broadcast.builder() + .time(Instant.parse("2021-01-01T11:36:43.123Z")) + .channel(DRONE_ZONE) + .song(dirkSerriesSongFixture()) + .build(); + Broadcast broadcast2 = Broadcast.builder() + .time(Instant.parse("2021-01-02T14:50:50.420Z")) + .channel(DRONE_ZONE) + .song(dirkSerriesSongFixture()) + .build(); + + // When + repository.updateBroadcasts(Set.of(broadcast1, broadcast2)); + + // Then + List broadcasts = testRepository.readAllBroadcasts(); + assertThat(broadcasts) + .hasSize(2) + .usingRecursiveFieldByFieldElementComparator() + .containsExactlyInAnyOrder(broadcast1, broadcast2); + + List songs = testRepository.readAllSongs(); + assertThat(songs) + .hasSize(1) + .usingRecursiveFieldByFieldElementComparator() + .containsExactlyInAnyOrder(dirkSerriesSongFixture()); + } + private static Dispatcher getDispatcher() throws IOException { URL url = Resources.getResource("data/dronezone.html"); String html = Resources.toString(url, StandardCharsets.UTF_8); diff --git a/src/test/resources/data/dronezone_with_one_duplicate.html b/src/test/resources/data/dronezone_with_one_duplicate.html new file mode 100644 index 0000000..72405b9 --- /dev/null +++ b/src/test/resources/data/dronezone_with_one_duplicate.html @@ -0,0 +1,257 @@ +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Played AtArtistSongAlbum
03:36:43  (Now)Dirk Serries' MicrophonicsVImicrophonics VI - XX
03:26:56Igneous + FlameIncandescent ArcLapiz +
03:20:05Snufmumriko + Further AfieldThis Tide Will Bring You + Home
03:19:55Break / Station ID
03:11:07xLWBxDRx + Droneuary XXXIII- UntitledDroneuary + 2019
02:59:39Ethereal + PlanesMaiden Voyage
02:51:24TransponderApproaching Sector VAmbient + Online + Compilation: Volume 9 (Part One)
02:42:56Loneward + AwakenProtection +
02:37:36Remote + VisionBirds Like EarthBirds Like Earth
02:35:07The + Dandelion CouncilIce CaveBeyond + Wist
02:31:14Dream + CycleDay +
02:14:12klangdicht + HimmelszeltSternenstaub +
02:04:58Howard Givens & Craig Padilla + Serenity, the Peaceful PlaceThe Bodhi Mantra
02:00:10The + AdelaideanHave Heart, Will TravelIsolation +
01:55:50Arvik + TorrenssenIslannin Kevat
01:50:02Ambient + AlchemyStrange FieldsShe Arrived In A Dream
01:44:08IsostaticSeagrass (#676E51)Earth + Tones
01:39:59Alphaxone + Road to NowhereEdge + of Solitude
01:35:56Eivind Aarset and Jan Bang + NightspellSnow Catches on Her + Eyelashes
01:29:11Dirk Serries' MicrophonicsVImicrophonics VI - XX
+