summaryrefslogtreecommitdiff
path: root/pse-server/src/main/java/org/psesquared/server/episode/actions/api
diff options
context:
space:
mode:
authorOrangerot <purple@orangerot.dev>2024-06-19 00:14:49 +0200
committerOrangerot <purple@orangerot.dev>2024-06-27 12:11:14 +0200
commit5b8851b6c268d0e93c158908fbfae9f8473db5ff (patch)
tree7010eb85d86fa2da06ea4ffbcdb01a685d502ae8 /pse-server/src/main/java/org/psesquared/server/episode/actions/api
Initial commitHEADmain
Diffstat (limited to 'pse-server/src/main/java/org/psesquared/server/episode/actions/api')
-rw-r--r--pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/EpisodeActionController.java129
-rw-r--r--pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/EpisodeActionGetResponse.java37
-rw-r--r--pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/EpisodeActionPost.java61
-rw-r--r--pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/package-info.java13
-rw-r--r--pse-server/src/main/java/org/psesquared/server/episode/actions/api/data/access/EpisodeActionDao.java107
-rw-r--r--pse-server/src/main/java/org/psesquared/server/episode/actions/api/data/access/EpisodeDao.java46
-rw-r--r--pse-server/src/main/java/org/psesquared/server/episode/actions/api/data/access/package-info.java13
-rw-r--r--pse-server/src/main/java/org/psesquared/server/episode/actions/api/service/EpisodeActionService.java360
-rw-r--r--pse-server/src/main/java/org/psesquared/server/episode/actions/api/service/package-info.java13
9 files changed, 779 insertions, 0 deletions
diff --git a/pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/EpisodeActionController.java b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/EpisodeActionController.java
new file mode 100644
index 0000000..9a0d898
--- /dev/null
+++ b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/EpisodeActionController.java
@@ -0,0 +1,129 @@
+package org.psesquared.server.episode.actions.api.controller;
+
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import org.psesquared.server.episode.actions.api.service.EpisodeActionService;
+import org.psesquared.server.util.UpdateUrlsWrapper;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * This is a controller class for the Episode Action API that
+ * handles the requests from the client concerning the synchronization
+ * of episodes between clients.
+ * In the end an appropriate response is sent back to the user.
+ */
+@RequestMapping("/api/2/episodes/{username}.json")
+@RestController
+@RequiredArgsConstructor
+public class EpisodeActionController {
+
+ /**
+ * The service class that this controller calls to further process requests.
+ */
+ private final EpisodeActionService episodeActionService;
+
+ /**
+ * Takes a list of EpisodeActionPosts of a user and adds them to the database.
+ *
+ * @param username The username of the user uploading the
+ * EpisodeActions
+ * @param episodeActionPosts The list of EpisodeActionPosts to be uploaded
+ * @return The exit status of the function
+ */
+ @PostMapping
+ public ResponseEntity<UpdateUrlsWrapper> addEpisodeActions(
+ @PathVariable final String username,
+ @RequestBody final List<EpisodeActionPost> episodeActionPosts) {
+ episodeActionService.addEpisodeActions(username, episodeActionPosts);
+ return ResponseEntity.ok(new UpdateUrlsWrapper());
+ }
+
+ /**
+ * Returns a list of all EpisodeActions a user has uploaded so far in the form
+ * of an EpisodeActionGetResponse.
+ *
+ * @param username The username of the user whose EpisodeActions are requested
+ * @return The exit status with a response body containing all requested
+ * EpisodeActions
+ */
+ @GetMapping
+ public ResponseEntity<EpisodeActionGetResponse> getEpisodeActions(
+ @PathVariable final String username) {
+ EpisodeActionGetResponse responseBody
+ = new EpisodeActionGetResponse(episodeActionService
+ .getEpisodeActions(username));
+ return ResponseEntity.ok(responseBody);
+ }
+
+ /**
+ * Returns a list of EpisodeActions of a user for a given podcast in the form
+ * of an EpisodeActionGetResponse.
+ *
+ * @param username The username of the user whose EpisodeActions are
+ * requested
+ * @param podcastUrl The RSS-Feed URL of the podcast in question
+ * @return The exit status with a response body containing all requested
+ * EpisodeActions
+ */
+ @GetMapping(params = {"podcast"})
+ public ResponseEntity<EpisodeActionGetResponse> getEpisodeActionsOfPodcast(
+ @PathVariable final String username,
+ @RequestParam("podcastUrl") final String podcastUrl) {
+ EpisodeActionGetResponse responseBody
+ = new EpisodeActionGetResponse(episodeActionService
+ .getEpisodeActionsOfPodcast(username, podcastUrl));
+ return ResponseEntity.ok(responseBody);
+ }
+
+ /**
+ * Returns a list of EpisodeActions of a user since a given timestamp in the
+ * form of an EpisodeActionGetResponse.
+ *
+ * @param username The username of the user whose EpisodeActions are requested
+ * @param since The timestamp signifying how old the EpisodeActions are
+ * allowed to be
+ * @return The exit status with a response body containing all requested
+ * EpisodeActions
+ */
+ @GetMapping(params = {"since"})
+ public ResponseEntity<EpisodeActionGetResponse> getEpisodeActionsSince(
+ @PathVariable final String username,
+ @RequestParam("since") final long since) {
+ EpisodeActionGetResponse responseBody
+ = new EpisodeActionGetResponse(episodeActionService
+ .getEpisodeActionsSince(username, since));
+ return ResponseEntity.ok(responseBody);
+ }
+
+ /**
+ * Returns a list of EpisodeActions of a user for a given podcast, since a
+ * given time in the form of an EpisodeActionGetResponse.
+ *
+ * @param username The username of the user whose EpisodeActions are
+ * requested
+ * @param podcastUrl The RSS-Feed URL of the podcast in question
+ * @param since The timestamp signifying how old the EpisodeActions are
+ * allowed to be
+ * @return The exit status with a response body containing all requested
+ * EpisodeActions
+ */
+ @GetMapping(params = {"podcast", "since"})
+ public ResponseEntity<EpisodeActionGetResponse>
+ getEpisodeActionsOfPodcastSince(
+ @PathVariable final String username,
+ @RequestParam("podcastUrl") final String podcastUrl,
+ @RequestParam("since") final long since) {
+ EpisodeActionGetResponse responseBody
+ = new EpisodeActionGetResponse(episodeActionService
+ .getEpisodeActionsOfPodcastSince(username, podcastUrl, since));
+ return ResponseEntity.ok(responseBody);
+ }
+
+}
diff --git a/pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/EpisodeActionGetResponse.java b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/EpisodeActionGetResponse.java
new file mode 100644
index 0000000..d1facac
--- /dev/null
+++ b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/EpisodeActionGetResponse.java
@@ -0,0 +1,37 @@
+package org.psesquared.server.episode.actions.api.controller;
+
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.List;
+import lombok.Data;
+
+/**
+ * The Response Object for a GET-Request concerning an EpisodeAction.
+ * <br>
+ * May contain multiple EpisodeActions.
+ */
+@Data
+public class EpisodeActionGetResponse {
+
+ /**
+ * The list of EpisodeActionPosts.
+ */
+ private final List<EpisodeActionPost> actions;
+
+ /**
+ * The timestamp of the response.
+ */
+ private final long timestamp;
+
+ /**
+ * Instantiates a new EpisodeActionGetResponse with the current timestamp.
+ *
+ * @param episodeActionPosts A list of EpisodeActionPosts
+ */
+ public EpisodeActionGetResponse(
+ final List<EpisodeActionPost> episodeActionPosts) {
+ this.actions = episodeActionPosts;
+ this.timestamp = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC);
+ }
+
+}
diff --git a/pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/EpisodeActionPost.java b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/EpisodeActionPost.java
new file mode 100644
index 0000000..28613f2
--- /dev/null
+++ b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/EpisodeActionPost.java
@@ -0,0 +1,61 @@
+package org.psesquared.server.episode.actions.api.controller;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonUnwrapped;
+import jakarta.validation.constraints.NotBlank;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.psesquared.server.model.EpisodeAction;
+
+/**
+ * An Episode Action that is being sent to the server via a POST Request.
+ * <br>
+ * If the user listened to an episode or did another action, an
+ * EpisodeActionPOST is uploaded.
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class EpisodeActionPost {
+
+ /**
+ * The URL of the podcast the posted episode action belongs to.
+ */
+ @JsonProperty(value = "podcast", required = true)
+ @NotBlank
+ private String podcastUrl;
+
+ /**
+ * The URL of the corresponding episode.
+ */
+ @JsonProperty(value = "episode", required = true)
+ @NotBlank
+ private String episodeUrl;
+
+ /**
+ * The title of the corresponding episode.
+ */
+ private String title;
+
+ /**
+ * The GUID of the corresponding episode.
+ */
+ private String guid;
+
+ /**
+ * The total length of the corresponding episode in milliseconds.
+ */
+ private int total;
+
+ /**
+ * The actual episode action whose attributes are presented unwrapped.
+ *
+ * @see JsonUnwrapped
+ */
+ @JsonUnwrapped
+ private EpisodeAction episodeAction;
+
+}
diff --git a/pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/package-info.java b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/package-info.java
new file mode 100644
index 0000000..3115012
--- /dev/null
+++ b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/package-info.java
@@ -0,0 +1,13 @@
+/**
+ * This package represents the highest logical layer of the episode action API
+ * ({@link org.psesquared.server.episode.actions.api}) - the controller layer.
+ * <br>
+ * It contains the
+ * {@link
+ * org.psesquared.server.episode.actions.api.controller.EpisodeActionController}
+ * along with some wrapper classes for JSON request and response bodies.
+ *
+ * @author PSE-Squared Team
+ * @version 1.0
+ */
+package org.psesquared.server.episode.actions.api.controller;
diff --git a/pse-server/src/main/java/org/psesquared/server/episode/actions/api/data/access/EpisodeActionDao.java b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/data/access/EpisodeActionDao.java
new file mode 100644
index 0000000..7f23312
--- /dev/null
+++ b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/data/access/EpisodeActionDao.java
@@ -0,0 +1,107 @@
+package org.psesquared.server.episode.actions.api.data.access;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+import org.psesquared.server.model.Action;
+import org.psesquared.server.model.EpisodeAction;
+import org.psesquared.server.model.User;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+/**
+ * A DAO interface responsible for transactions involving EpisodeActions.
+ */
+@Repository
+public interface EpisodeActionDao extends JpaRepository<EpisodeAction, Long> {
+
+ /**
+ * Find all EpisodeActions a user has uploaded.
+ *
+ * @param username The username of the user who uploaded the EpisodeActions
+ * @return The list of EpisodeActions regarding the user
+ */
+ List<EpisodeAction> findByUserUsername(String username);
+
+ /**
+ * Find all EpisodeActions of a user that concern a certain podcast identified
+ * by its RSS-Feed URL.
+ *
+ * @param username The username of the user who uploaded the EpisodeActions
+ * @param url The RSS-Feed URL of the podcast in question
+ * @return The list of EpisodeActions regarding the user and the given podcast
+ */
+ List<EpisodeAction> findByUserUsernameAndEpisodeSubscriptionUrl(
+ String username,
+ String url);
+
+ /**
+ * Deletes all EpisodeActions of a podcast for a user.
+ *
+ * @param username The username of the user
+ * @param url The podcast URL
+ */
+ void deleteByUserUsernameAndEpisodeSubscriptionUrl(
+ String username,
+ String url);
+
+ /**
+ * Checks if an EpisodeAction of the specified action type for a given user
+ * and episode already exists.
+ *
+ * @param user The user
+ * @param url The episode URL
+ * @param action The type of action
+ * @return {@code true} if such an EpisodeAction exists,
+ * <br>
+ * {@code false} otherwise
+ */
+ boolean existsByUserAndEpisodeUrlAndAction(User user,
+ String url,
+ Action action);
+
+ /**
+ * Finds the EpisodeAction of the specified action type and of an Episode
+ * for a User.
+ *
+ * @param user The user
+ * @param url The episode URL
+ * @param action The type of action
+ * @return An {@link Optional} containing the EpisodeAction if present
+ */
+ Optional<EpisodeAction> findByUserAndEpisodeUrlAndAction(User user,
+ String url,
+ Action action);
+
+ /**
+ * Find all EpisodeActions of a user since a given timestamp.
+ *
+ * @param username The username of the user
+ * @param timestamp The timestamp signifying how old an EpisodeAction is
+ * allowed
+ * to be
+ * @return A list containing all EpisodeActions not older than the timestamp
+ */
+ List<EpisodeAction> findByUserUsernameAndTimestampGreaterThanEqual(
+ String username,
+ LocalDateTime timestamp);
+
+ /**
+ * Find all EpisodeActions of a user since a given timestamp of a given
+ * podcast.
+ *
+ * @param username The username of the user
+ * @param timestamp The timestamp signifying how old an EpisodeAction is
+ * allowed to be
+ * @param url The RSS-Feed URL of the podcast whose EpisodeActions are
+ * requested
+ * @return A list containing all EpisodeActions of the given podcast not older
+ * than the timestamp
+ */
+ List<EpisodeAction>
+ findByUserUsernameAndTimestampGreaterThanEqualAndEpisodeSubscriptionUrl(
+ String username,
+ LocalDateTime timestamp,
+ String url);
+
+}
diff --git a/pse-server/src/main/java/org/psesquared/server/episode/actions/api/data/access/EpisodeDao.java b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/data/access/EpisodeDao.java
new file mode 100644
index 0000000..16c647f
--- /dev/null
+++ b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/data/access/EpisodeDao.java
@@ -0,0 +1,46 @@
+package org.psesquared.server.episode.actions.api.data.access;
+
+import java.util.Optional;
+import org.psesquared.server.model.Episode;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+/**
+ * A DAO interface responsible for transactions involving Episodes.
+ */
+@Repository
+public interface EpisodeDao extends JpaRepository<Episode, Long> {
+
+ /**
+ * Find an episode by its URL.
+ *
+ * @param url The URL of the episode
+ * @return The matching episode / NULL, if there was no match.
+ */
+ Optional<Episode> findByUrl(String url);
+
+ /**
+ * Returns true if there is an episode that matches a given URL.
+ *
+ * @param url The URL of the episode
+ * @return A boolean value signifying whether the episode exists
+ */
+ boolean existsByUrl(String url);
+
+ /**
+ * Returns true if there is an episode that matches a given GUID.
+ *
+ * @param guid The GUID of the episode
+ * @return A boolean value signifying whether the episode exists
+ */
+ boolean existsByGuid(String guid);
+
+ /**
+ * Find an episode by its GUID.
+ *
+ * @param guid The GUID of the episode
+ * @return The matching episode / NULL, if there was no match.
+ */
+ Optional<Episode> findByGuid(String guid);
+
+}
diff --git a/pse-server/src/main/java/org/psesquared/server/episode/actions/api/data/access/package-info.java b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/data/access/package-info.java
new file mode 100644
index 0000000..b32f249
--- /dev/null
+++ b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/data/access/package-info.java
@@ -0,0 +1,13 @@
+/**
+ * This package represents the lowest logical layer of the episode action API
+ * ({@link org.psesquared.server.episode.actions.api}) - the data-access layer.
+ * <br>
+ * It features the interfaces {@link
+ * org.psesquared.server.episode.actions.api.data.access.EpisodeActionDao}
+ * and {@link
+ * org.psesquared.server.episode.actions.api.data.access.EpisodeDao}.
+ *
+ * @author PSE-Squared Team
+ * @version 1.0
+ */
+package org.psesquared.server.episode.actions.api.data.access;
diff --git a/pse-server/src/main/java/org/psesquared/server/episode/actions/api/service/EpisodeActionService.java b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/service/EpisodeActionService.java
new file mode 100644
index 0000000..8c4f93a
--- /dev/null
+++ b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/service/EpisodeActionService.java
@@ -0,0 +1,360 @@
+package org.psesquared.server.episode.actions.api.service;
+
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import lombok.RequiredArgsConstructor;
+import org.psesquared.server.authentication.api.data.access.AuthenticationDao;
+import org.psesquared.server.episode.actions.api.controller.EpisodeActionPost;
+import org.psesquared.server.episode.actions.api.data.access.EpisodeActionDao;
+import org.psesquared.server.episode.actions.api.data.access.EpisodeDao;
+import org.psesquared.server.model.Action;
+import org.psesquared.server.model.Episode;
+import org.psesquared.server.model.EpisodeAction;
+import org.psesquared.server.model.Subscription;
+import org.psesquared.server.model.SubscriptionAction;
+import org.psesquared.server.model.User;
+import org.psesquared.server.subscriptions.api.data.access.SubscriptionActionDao;
+import org.psesquared.server.subscriptions.api.data.access.SubscriptionDao;
+import org.psesquared.server.util.RssParser;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * This service class manages all business logic associated with the
+ * episode action API.
+ * <br>
+ * It is called from the
+ * {@link
+ * org.psesquared.server.episode.actions.api.controller.EpisodeActionController}
+ * and passes on requests concerning data access mainly to the
+ * {@link EpisodeDao} and {@link EpisodeActionDao}.
+ */
+@Service
+@Transactional
+@RequiredArgsConstructor
+public class EpisodeActionService {
+
+ /**
+ * The nano of second default value for
+ * {@link LocalDateTime#ofEpochSecond(long, int, ZoneOffset)}.
+ */
+ private static final int NANO_OF_SECOND_DEFAULT = 0;
+
+ /**
+ * The JPA repository that handles all episode action related database
+ * requests.
+ */
+ private final EpisodeActionDao episodeActionDao;
+
+ /**
+ * The JPA repository that handles all episode related database requests.
+ */
+ private final EpisodeDao episodeDao;
+
+ /**
+ * The JPA repository that handles all user related database requests.
+ */
+ private final AuthenticationDao authenticationDao;
+
+ /**
+ * The JPA repository that handles all subscription related database requests.
+ */
+ private final SubscriptionDao subscriptionDao;
+
+ /**
+ * The JPA repository that handles all subscription action related database
+ * requests.
+ */
+ private final SubscriptionActionDao subscriptionActionDao;
+
+ /**
+ * The class for asynchronously fetching data from RSS feeds.
+ */
+ private final RssParser rssParser;
+
+ /**
+ * A map of subscription that need to be fetched with the {@link RssParser}.
+ */
+ private final Map<String, Subscription> subscriptionsToFetch
+ = new HashMap<>();
+
+ /**
+ * Takes a list of EpisodeActionPosts, converts them to EpisodeActions and
+ * saves them to the database.
+ *
+ * @param username The username of the user who uploads these
+ * EpisodeActions
+ * @param episodeActionPosts List of EpisodeActionPosts that were sent via the
+ * POST request
+ */
+ public void addEpisodeActions(
+ final String username,
+ final List<EpisodeActionPost> episodeActionPosts) {
+ User user = authenticationDao.findByUsername(username).orElseThrow();
+ List<EpisodeActionPost> filteredEpisodeActionPosts
+ = new ArrayList<>(filterNewestAction(episodeActionPosts));
+ List<EpisodeAction> episodeActions
+ = episodeActionPostsToEpisodeActions(user, filteredEpisodeActionPosts);
+ addEpisodeActionsToDatabase(user, episodeActions);
+ validateDummyEpisodes();
+ }
+
+ private Collection<EpisodeActionPost> filterNewestAction(
+ final List<EpisodeActionPost> episodeActionPosts) {
+ Map<String, EpisodeActionPost> relevantEpisodeActionPosts = new HashMap<>();
+ for (EpisodeActionPost episodeActionPost : episodeActionPosts) {
+ if (episodeActionPost.getEpisodeAction().getAction() != Action.PLAY) {
+ continue;
+ }
+ String url = episodeActionPost.getEpisodeUrl();
+ if (relevantEpisodeActionPosts.containsKey(url)) {
+ EpisodeActionPost currentEpisodeActionPost
+ = relevantEpisodeActionPosts.get(url);
+ if (episodeActionPost.getEpisodeAction().getTimestamp()
+ .isAfter(
+ currentEpisodeActionPost.getEpisodeAction().getTimestamp())) {
+ relevantEpisodeActionPosts.put(url, episodeActionPost);
+ }
+ } else {
+ relevantEpisodeActionPosts.put(url, episodeActionPost);
+ }
+ }
+ return relevantEpisodeActionPosts.values();
+ }
+
+ private List<EpisodeAction> episodeActionPostsToEpisodeActions(
+ final User user,
+ final List<EpisodeActionPost> episodeActionPosts) {
+ List<EpisodeAction> episodeActions = new ArrayList<>();
+ for (EpisodeActionPost episodeActionPost : episodeActionPosts) {
+ if (episodeActionPost.getEpisodeAction().getAction() == Action.PLAY) {
+ episodeActions.add(episodeActionPostToEpisodeAction(
+ user,
+ episodeActionPost));
+ }
+ }
+ return episodeActions;
+ }
+
+ private EpisodeAction episodeActionPostToEpisodeAction(
+ final User user,
+ final EpisodeActionPost episodeActionPost) {
+ EpisodeAction episodeAction = episodeActionPost.getEpisodeAction();
+ episodeAction.setUser(user);
+ // If Subscription does not exist, create dummy Subscription
+ Subscription subscription = null;
+ if (!subscriptionDao.existsByUrl(episodeActionPost.getPodcastUrl())) {
+ subscription = new Subscription();
+ subscription.setTimestamp(
+ LocalDateTime.now().toEpochSecond(ZoneOffset.UTC));
+ subscription.setUrl(episodeActionPost.getPodcastUrl());
+ subscription = subscriptionDao.save(subscription);
+ // create Subscription Action
+ SubscriptionAction subscriptionAction = SubscriptionAction.builder()
+ .user(user)
+ .added(true)
+ .subscription(
+ subscriptionDao.findByUrl(
+ episodeActionPost.getPodcastUrl()).orElseThrow())
+ .timestamp(LocalDateTime.now().toEpochSecond(ZoneOffset.UTC))
+ .build();
+ subscriptionActionDao.save(subscriptionAction);
+ } else {
+ subscription = subscriptionDao
+ .findByUrl(episodeActionPost.getPodcastUrl()).orElseThrow();
+ }
+ Episode episode = getEpisodeFromDatabase(episodeActionPost);
+ episodeAction.setEpisode(episode);
+ subscription.addEpisode(episode);
+ return episodeAction;
+ }
+
+ private Episode getEpisodeFromDatabase(
+ final EpisodeActionPost episodeActionPost) {
+ Episode episode;
+ String episodeUrl = episodeActionPost.getEpisodeUrl();
+ String episodeGuid = episodeActionPost.getGuid();
+ // If guid is passed and a matching episode exists get it
+ if (episodeGuid != null && episodeDao.existsByGuid(episodeGuid)) {
+ episode = episodeDao.findByGuid(episodeGuid).orElseThrow();
+ } else if (episodeDao.existsByUrl(episodeUrl)) {
+ // No episode with matching guid found -> search by url
+ episode = episodeDao.findByUrl(episodeUrl).orElseThrow();
+ // If guid was passed, pass it along to the database
+ if (episodeGuid != null) {
+ episode.setGuid(episodeGuid);
+ episodeDao.save(episode);
+ }
+ } else {
+ // Episode does not exist, so construct a new one
+ episode = createEpisode(episodeActionPost);
+ }
+ return episode;
+ }
+
+ private Episode createEpisode(final EpisodeActionPost episodeActionPost) {
+ Episode episode = Episode.builder()
+ .title(episodeActionPost.getTitle())
+ .url(episodeActionPost.getEpisodeUrl())
+ .total(episodeActionPost.getTotal())
+ .subscription(subscriptionDao
+ .findByUrl(episodeActionPost.getPodcastUrl()).orElseThrow())
+ .build();
+ if (episodeActionPost.getGuid() != null) {
+ episode.setGuid(episodeActionPost.getGuid());
+ }
+ episodeDao.save(episode);
+ Subscription subscription = episode.getSubscription();
+ subscriptionsToFetch.put(subscription.getUrl(), subscription);
+ return episode;
+ }
+
+ private void addEpisodeActionsToDatabase(
+ final User user,
+ final List<EpisodeAction> episodeActions) {
+ for (EpisodeAction episodeAction : episodeActions) {
+ addEpisodeActionToDatabase(user, episodeAction);
+ }
+ }
+
+ private void addEpisodeActionToDatabase(
+ final User user,
+ final EpisodeAction episodeAction) {
+ if (episodeActionDao.existsByUserAndEpisodeUrlAndAction(
+ user,
+ episodeAction.getEpisode().getUrl(),
+ episodeAction.getAction())) {
+ addNewestEpisodeActionToDatabase(user, episodeAction);
+ } else {
+ episodeActionDao.save(episodeAction);
+ }
+ }
+
+ private void addNewestEpisodeActionToDatabase(
+ final User user,
+ final EpisodeAction episodeAction) {
+ EpisodeAction oldEpisodeAction
+ = episodeActionDao.findByUserAndEpisodeUrlAndAction(
+ user,
+ episodeAction.getEpisode().getUrl(),
+ episodeAction.getAction()).orElseThrow();
+ if (episodeAction.getTimestamp().isAfter(oldEpisodeAction.getTimestamp())) {
+ episodeActionDao.delete(oldEpisodeAction);
+ episodeActionDao.save(episodeAction);
+ }
+ }
+
+ private void validateDummyEpisodes() {
+ Collection<Subscription> subscriptions = subscriptionsToFetch.values();
+ for (Subscription subscription : subscriptions) {
+ rssParser.validate(subscription);
+ }
+ subscriptionsToFetch.clear();
+ }
+
+ /**
+ * Gets all EpisodeActions of a user and converts them to EpisodeActionPosts
+ * before returning them.
+ *
+ * @param username The username of the user whose EpisodeActions are requested
+ * @return A list containing the requested EpisodeActions as
+ * EpisodeActionPosts
+ */
+ public List<EpisodeActionPost> getEpisodeActions(final String username) {
+ List<EpisodeAction> episodeActions
+ = episodeActionDao.findByUserUsername(username);
+ return episodeActionsToEpisodeActionPosts(episodeActions);
+ }
+
+ /**
+ * Gets all EpisodeActions of a user that correspond to a given podcast.
+ * Returns the EpisodeActions after converting them to EpisodeActionPosts.
+ *
+ * @param username The username of the user whose EpisodeActions are
+ * requested
+ * @param podcastUrl The RSS-Feed URL of the podcast
+ * @return A list containing the requested EpisodeActions as
+ * EpisodeActionPosts
+ */
+ public List<EpisodeActionPost> getEpisodeActionsOfPodcast(
+ final String username,
+ final String podcastUrl) {
+ List<EpisodeAction> episodeActions
+ = episodeActionDao.findByUserUsernameAndEpisodeSubscriptionUrl(
+ username,
+ podcastUrl);
+ return episodeActionsToEpisodeActionPosts(episodeActions);
+ }
+
+ /**
+ * Gets all EpisodeActions of a user since a given timestamp and converts them
+ * to EpisodeActionPosts before returning them.
+ *
+ * @param username The username of the user whose EpisodeActions are requested
+ * @param since the timestamp signifying how old the EpisodeActions are
+ * allowed to be
+ * @return A list containing the requested EpisodeActions as
+ * EpisodeActionPosts
+ */
+ public List<EpisodeActionPost> getEpisodeActionsSince(
+ final String username,
+ final long since) {
+ LocalDateTime sinceTimestamp
+ = LocalDateTime.ofEpochSecond(
+ since,
+ NANO_OF_SECOND_DEFAULT,
+ ZoneOffset.UTC);
+ List<EpisodeAction> episodeActions
+ = episodeActionDao.findByUserUsernameAndTimestampGreaterThanEqual(
+ username,
+ sinceTimestamp);
+ return episodeActionsToEpisodeActionPosts(episodeActions);
+ }
+
+ /**
+ * Gets all EpisodeActions of a user concerning a certain podcast since a
+ * given timestamp and converts them to EpisodeActionPosts before returning
+ * them.
+ *
+ * @param username The username of the user whose EpisodeActions are
+ * requested
+ * @param podcastUrl The RSS-Feed URL of the podcast
+ * @param since The timestamp signifying how old the EpisodeActions are
+ * allowed to be
+ * @return A list containing the requested EpisodeActions as
+ * EpisodeActionPosts
+ */
+ public List<EpisodeActionPost> getEpisodeActionsOfPodcastSince(
+ final String username,
+ final String podcastUrl,
+ final long since) {
+ LocalDateTime sinceTimestamp
+ = LocalDateTime.ofEpochSecond(
+ since,
+ NANO_OF_SECOND_DEFAULT,
+ ZoneOffset.UTC);
+ List<EpisodeAction> episodeActions = episodeActionDao
+ .findByUserUsernameAndTimestampGreaterThanEqualAndEpisodeSubscriptionUrl(
+ username,
+ sinceTimestamp,
+ podcastUrl);
+ return episodeActionsToEpisodeActionPosts(episodeActions);
+ }
+
+ private List<EpisodeActionPost> episodeActionsToEpisodeActionPosts(
+ final List<EpisodeAction> episodeActions) {
+ List<EpisodeActionPost> episodeActionPosts = new ArrayList<>();
+
+ for (EpisodeAction episodeAction : episodeActions) {
+ episodeActionPosts.add(episodeAction.toEpisodeActionPost());
+ }
+
+ return episodeActionPosts;
+ }
+
+}
diff --git a/pse-server/src/main/java/org/psesquared/server/episode/actions/api/service/package-info.java b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/service/package-info.java
new file mode 100644
index 0000000..c431539
--- /dev/null
+++ b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/service/package-info.java
@@ -0,0 +1,13 @@
+/**
+ * This package represents the logical middle layer of the episode action API
+ * ({@link org.psesquared.server.episode.actions.api}) - the service layer.
+ * <br>
+ * All business logic is handled here with the
+ * {@link
+ * org.psesquared.server.episode.actions.api.service.EpisodeActionService}
+ * class.
+ *
+ * @author PSE-Squared Team
+ * @version 1.0
+ */
+package org.psesquared.server.episode.actions.api.service;