From 2ecde5dc8df81941106e51fe7a1e823ebc627fc4 Mon Sep 17 00:00:00 2001 From: Nihal Mirpuri Date: Thu, 16 Nov 2023 21:35:10 +0000 Subject: [PATCH 1/2] refactor classes, introducing modularity in the code --- src/main/scala/Server.scala | 2 +- src/main/scala/WatchlistSync.scala | 148 ++------------- .../scala/configuration/Configuration.scala | 161 +---------------- .../configuration/ConfigurationReader.scala | 4 - .../configuration/ConfigurationUtils.scala | 168 ++++++++++++++++++ src/main/scala/configuration/Keys.scala | 2 +- .../scala/configuration/QualityProfile.scala | 3 + src/main/scala/configuration/RootFolder.scala | 3 + .../configuration/SystemPropertyReader.scala | 5 + .../scala/{utils => http}/HttpClient.scala | 6 +- src/main/scala/model/Item.scala | 16 +- src/main/scala/model/QualityProfile.scala | 3 - src/main/scala/model/RadarrMovie.scala | 7 - src/main/scala/model/RootFolder.scala | 3 - src/main/scala/model/SonarrSeries.scala | 3 - src/main/scala/model/Watchlist.scala | 3 - src/main/scala/plex/PlexUtils.scala | 26 +++ src/main/scala/plex/Watchlist.scala | 5 + src/main/scala/radarr/AddOptions.scala | 3 + src/main/scala/radarr/RadarrConversions.scala | 13 ++ src/main/scala/radarr/RadarrMovie.scala | 3 + .../scala/radarr/RadarrMovieExclusion.scala | 3 + src/main/scala/radarr/RadarrPost.scala | 3 + src/main/scala/radarr/RadarrUtils.scala | 62 +++++++ src/main/scala/sonarr/SonarrAddOptions.scala | 3 + src/main/scala/sonarr/SonarrConversions.scala | 11 ++ src/main/scala/sonarr/SonarrPost.scala | 3 + src/main/scala/sonarr/SonarrSeries.scala | 3 + src/main/scala/sonarr/SonarrUtils.scala | 63 +++++++ src/main/scala/utils/ArrUtils.scala | 15 -- src/test/scala/WatchlistSyncSpec.scala | 80 --------- ...pec.scala => ConfigurationUtilsSpec.scala} | 7 +- src/test/scala/model/ItemSpec.scala | 91 ++++++++++ src/test/scala/plex/PlexUtilsSpec.scala | 47 +++++ src/test/scala/radarr/RadarrUtilsSpec.scala | 62 +++++++ src/test/scala/sonarr/SonarrUtilsSpec.scala | 62 +++++++ 36 files changed, 679 insertions(+), 423 deletions(-) create mode 100644 src/main/scala/configuration/ConfigurationUtils.scala create mode 100644 src/main/scala/configuration/QualityProfile.scala create mode 100644 src/main/scala/configuration/RootFolder.scala create mode 100644 src/main/scala/configuration/SystemPropertyReader.scala rename src/main/scala/{utils => http}/HttpClient.scala (98%) delete mode 100644 src/main/scala/model/QualityProfile.scala delete mode 100644 src/main/scala/model/RadarrMovie.scala delete mode 100644 src/main/scala/model/RootFolder.scala delete mode 100644 src/main/scala/model/SonarrSeries.scala delete mode 100644 src/main/scala/model/Watchlist.scala create mode 100644 src/main/scala/plex/PlexUtils.scala create mode 100644 src/main/scala/plex/Watchlist.scala create mode 100644 src/main/scala/radarr/AddOptions.scala create mode 100644 src/main/scala/radarr/RadarrConversions.scala create mode 100644 src/main/scala/radarr/RadarrMovie.scala create mode 100644 src/main/scala/radarr/RadarrMovieExclusion.scala create mode 100644 src/main/scala/radarr/RadarrPost.scala create mode 100644 src/main/scala/radarr/RadarrUtils.scala create mode 100644 src/main/scala/sonarr/SonarrAddOptions.scala create mode 100644 src/main/scala/sonarr/SonarrConversions.scala create mode 100644 src/main/scala/sonarr/SonarrPost.scala create mode 100644 src/main/scala/sonarr/SonarrSeries.scala create mode 100644 src/main/scala/sonarr/SonarrUtils.scala delete mode 100644 src/main/scala/utils/ArrUtils.scala delete mode 100644 src/test/scala/WatchlistSyncSpec.scala rename src/test/scala/configuration/{ConfigurationSpec.scala => ConfigurationUtilsSpec.scala} (97%) create mode 100644 src/test/scala/model/ItemSpec.scala create mode 100644 src/test/scala/plex/PlexUtilsSpec.scala create mode 100644 src/test/scala/radarr/RadarrUtilsSpec.scala create mode 100644 src/test/scala/sonarr/SonarrUtilsSpec.scala diff --git a/src/main/scala/Server.scala b/src/main/scala/Server.scala index fa1f719..09f887a 100644 --- a/src/main/scala/Server.scala +++ b/src/main/scala/Server.scala @@ -1,8 +1,8 @@ import cats.effect._ import configuration.{Configuration, ConfigurationUtils, SystemPropertyReader} +import http.HttpClient import org.slf4j.LoggerFactory -import utils.HttpClient import java.nio.channels.ClosedChannelException diff --git a/src/main/scala/WatchlistSync.scala b/src/main/scala/WatchlistSync.scala index 950e72d..5ac2742 100644 --- a/src/main/scala/WatchlistSync.scala +++ b/src/main/scala/WatchlistSync.scala @@ -1,14 +1,15 @@ import cats.effect.IO -import org.http4s.{Method, Uri} import cats.implicits._ import configuration.Configuration -import io.circe.generic.auto._ -import io.circe.syntax._ -import model._ +import http.HttpClient +import model.Item import org.slf4j.LoggerFactory -import utils.{ArrUtils, HttpClient} +import plex.PlexUtils +import radarr.RadarrUtils +import sonarr.SonarrUtils -object WatchlistSync { +object WatchlistSync + extends SonarrUtils with RadarrUtils with PlexUtils { private val logger = LoggerFactory.getLogger(getClass) @@ -17,100 +18,18 @@ object WatchlistSync { logger.debug("Starting watchlist sync") for { - watchlistDatas <- config.plexWatchlistUrls.map(fetchWatchlist(client)).sequence - watchlistData = watchlistDatas.fold(Watchlist(Set.empty))(mergeWatchLists) + watchlistDatas <- config.plexWatchlistUrls.map(fetchWatchlistFromRss(client)).sequence + watchlistData = watchlistDatas.flatten.toSet movies <- fetchMovies(client)(config.radarrApiKey, config.radarrBaseUrl, config.radarrBypassIgnored) series <- fetchSeries(client)(config.sonarrApiKey, config.sonarrBaseUrl, config.sonarrBypassIgnored) - allIds = merge(movies, series) - _ <- missingIds(client)(config)(allIds, watchlistData.items) + allIds = movies ++ series + _ <- missingIds(client)(config)(allIds, watchlistData) } yield () } - private def mergeWatchLists(l: Watchlist, r: Watchlist): Watchlist = Watchlist(l.items ++ r.items) - - private def fetchWatchlist(client: HttpClient)(url: Uri): IO[Watchlist] = - client.httpRequest(Method.GET, url).map { - case Left(err) => - logger.warn(s"Unable to fetch watchlist from Plex: $err") - Watchlist(Set.empty) - case Right(json) => - logger.debug("Found Json from Plex watchlist, attempting to decode") - json.as[Watchlist].getOrElse { - logger.warn("Unable to fetch watchlist from Plex - decoding failure. Returning empty list instead") - Watchlist(Set.empty) - } - } - - private def fetchMovies(client: HttpClient)(apiKey: String, baseUrl: Uri, bypass: Boolean): IO[List[RadarrMovie]] = - for { - movies <- ArrUtils.getToArr(client)(baseUrl, apiKey, "movie").map { - case Right(res) => - res.as[List[RadarrMovie]].getOrElse { - logger.warn("Unable to fetch movies from Radarr - decoding failure. Returning empty list instead") - List.empty - } - case Left(err) => - logger.warn(s"Received error while trying to fetch movies from Radarr: $err") - List.empty - } - exclusions <- if (bypass) { - IO.pure(List.empty) - } else { - ArrUtils.getToArr(client)(baseUrl, apiKey, "exclusions").map { - case Right(res) => - res.as[List[RadarrMovieExclusion]].getOrElse { - logger.warn("Unable to fetch movie exclusions from Radarr - decoding failure. Returning empty list instead") - List.empty - } - case Left(err) => - logger.warn(s"Received error while trying to fetch movie exclusions from Radarr: $err") - List.empty - } - } - } yield movies ++ exclusions.map(_.toRadarrMovie) - - private def fetchSeries(client: HttpClient)(apiKey: String, baseUrl: Uri, bypass: Boolean): IO[List[SonarrSeries]] = - for { - shows <- ArrUtils.getToArr(client)(baseUrl, apiKey, "series").map { - case Right(res) => - res.as[List[SonarrSeries]].getOrElse { - logger.warn("Unable to fetch series from Sonarr - decoding failure. Returning empty list instead") - List.empty - } - case Left(err) => - logger.warn(s"Received error while trying to fetch movies from Radarr: $err") - List.empty - } - exclusions <- if (bypass) { - IO.pure(List.empty) - } else { - ArrUtils.getToArr(client)(baseUrl, apiKey, "importlistexclusion").map { - case Right(res) => - res.as[List[SonarrSeries]].getOrElse { - logger.warn("Unable to fetch show exclusions from Sonarr - decoding failure. Returning empty list instead") - List.empty - } - case Left(err) => - logger.warn(s"Received error while trying to fetch show exclusions from Sonarr: $err") - List.empty - } - } - } yield shows ++ exclusions - - - private def merge(r: List[RadarrMovie], s: List[SonarrSeries]): Set[String] = { - val allIds = r.map(_.imdbId) ++ r.map(_.tmdbId) ++ s.map(_.imdbId) ++ s.map(_.tvdbId) - - allIds.collect { - case Some(x) => x.toString - }.toSet - } - - private def missingIds(client: HttpClient)(config: Configuration)(allIds: Set[String], watchlist: Set[Item]): IO[Set[Unit]] = + private def missingIds(client: HttpClient)(config: Configuration)(existingItems: Set[Item], watchlist: Set[Item]): IO[Set[Unit]] = watchlist.map { watchlistedItem => - val watchlistIds = watchlistedItem.guids.map(cleanId).toSet - - (watchlistIds.exists(allIds.contains), watchlistedItem.category) match { + (existingItems.exists(_.matches(watchlistedItem)), watchlistedItem.category) match { case (true, c) => logger.debug(s"$c \"${watchlistedItem.title}\" already exists in Sonarr/Radarr") IO.unit @@ -126,45 +45,4 @@ object WatchlistSync { } }.toList.sequence.map(_.toSet) - private def cleanId: String => String = _.split("://").last - - private case class RadarrPost(title: String, tmdbId: Long, qualityProfileId: Int = 6, rootFolderPath: String, addOptions: AddOptions = AddOptions()) - - private case class AddOptions(searchForMovie: Boolean = true) - - private def findTmdbId(strings: List[String]): Option[Long] = - strings.find(_.startsWith("tmdb://")).flatMap(_.stripPrefix("tmdb://").toLongOption) - - private def addToRadarr(client: HttpClient)(config: Configuration)(item: Item): IO[Unit] = { - - val movie = RadarrPost(item.title, findTmdbId(item.guids).getOrElse(0L), config.radarrQualityProfileId, config.radarrRootFolder) - - ArrUtils.postToArr(client)(config.radarrBaseUrl, config.radarrApiKey, "movie")(movie.asJson).map { - case Right(_) => - logger.info(s"Successfully added movie ${item.title} to Radarr") - case Left(err) => - logger.error(s"Failed to add movie ${item.title}: $err") - } - } - - private case class SonarrPost(title: String, tvdbId: Long, qualityProfileId: Int, rootFolderPath: String, addOptions: SonarrAddOptions, monitored: Boolean = true) - - private case class SonarrAddOptions(monitor: String, searchForCutoffUnmetEpisodes: Boolean = true, searchForMissingEpisodes: Boolean = true) - - private def findTvdbId(strings: List[String]): Option[Long] = - strings.find(_.startsWith("tvdb://")).flatMap(_.stripPrefix("tvdb://").toLongOption) - - private def addToSonarr(client: HttpClient)(config: Configuration)(item: Item): IO[Unit] = { - - val sonarrAddOptions = SonarrAddOptions(config.sonarrSeasonMonitoring) - val show = SonarrPost(item.title, findTvdbId(item.guids).getOrElse(0L), config.sonarrQualityProfileId, config.sonarrRootFolder, sonarrAddOptions) - - ArrUtils.postToArr(client)(config.sonarrBaseUrl, config.sonarrApiKey, "series")(show.asJson).map { - case Right(_) => - logger.info(s"Successfully added show ${item.title} to Sonarr") - case Left(err) => - logger.info(s"Failed to add show ${item.title}: $err") - } - } - } diff --git a/src/main/scala/configuration/Configuration.scala b/src/main/scala/configuration/Configuration.scala index 03d31f6..c36a684 100644 --- a/src/main/scala/configuration/Configuration.scala +++ b/src/main/scala/configuration/Configuration.scala @@ -1,13 +1,8 @@ package configuration -import cats.effect.IO -import io.circe.generic.auto._ -import model.{QualityProfile, RootFolder} import org.http4s.Uri -import org.slf4j.LoggerFactory -import utils.{ArrUtils, HttpClient} -import scala.concurrent.duration._ +import scala.concurrent.duration.FiniteDuration case class Configuration( refreshInterval: FiniteDuration, @@ -24,157 +19,3 @@ case class Configuration( radarrBypassIgnored: Boolean, plexWatchlistUrls: List[Uri] ) - -object ConfigurationUtils { - - private val logger = LoggerFactory.getLogger(getClass) - - def create(configReader: ConfigurationReader, client: HttpClient): IO[Configuration] = - for { - sonarrConfig <- getSonarrConfig(configReader, client) - refreshInterval = configReader.getConfigOption(Keys.intervalSeconds).flatMap(_.toIntOption).getOrElse(60).seconds - (sonarrBaseUrl, sonarrApiKey, sonarrQualityProfileId, sonarrRootFolder) = sonarrConfig - sonarrBypassIgnored = configReader.getConfigOption(Keys.sonarrBypassIgnored).exists(_.toBoolean) - sonarrSeasonMonitoring = configReader.getConfigOption(Keys.sonarrSeasonMonitoring).getOrElse("all") - radarrConfig <- getRadarrConfig(configReader, client) - (radarrBaseUrl, radarrApiKey, radarrQualityProfileId, radarrRootFolder) = radarrConfig - radarrBypassIgnored = configReader.getConfigOption(Keys.radarrBypassIgnored).exists(_.toBoolean) - plexWatchlistUrls = getPlexWatchlistUrls(configReader) - } yield Configuration( - refreshInterval, - sonarrBaseUrl, - sonarrApiKey, - sonarrQualityProfileId, - sonarrRootFolder, - sonarrBypassIgnored, - sonarrSeasonMonitoring, - radarrBaseUrl, - radarrApiKey, - radarrQualityProfileId, - radarrRootFolder, - radarrBypassIgnored, - plexWatchlistUrls - ) - - private def getSonarrConfig(configReader: ConfigurationReader, client: HttpClient): IO[(Uri, String, Int, String)] = { - val url = configReader.getConfigOption(Keys.sonarrBaseUrl).flatMap(Uri.fromString(_).toOption).getOrElse { - val default = "http://localhost:8989" - logger.warn(s"Unable to fetch sonarr baseUrl, using default $default") - Uri.unsafeFromString(default) - } - val apiKey = configReader.getConfigOption(Keys.sonarrApiKey).getOrElse(throwError("Unable to find sonarr API key")) - - ArrUtils.getToArr(client)(url, apiKey, "rootFolder").map { - case Right(res) => - logger.info("Successfully connected to Sonarr") - val allRootFolders = res.as[List[RootFolder]].getOrElse(List.empty) - selectRootFolder(allRootFolders, configReader.getConfigOption(Keys.sonarrRootFolder)) - case Left(err) => - throwError(s"Unable to connect to Sonarr at $url, with error $err") - }.flatMap(rootFolder => - ArrUtils.getToArr(client)(url, apiKey, "qualityprofile").map { - case Right(res) => - val allQualityProfiles = res.as[List[QualityProfile]].getOrElse(List.empty) - val chosenQualityProfile = configReader.getConfigOption(Keys.sonarrQualityProfile) - (url, apiKey, getQualityProfileId(allQualityProfiles, chosenQualityProfile), rootFolder) - case Left(err) => - throwError(s"Unable to connect to Sonarr at $url, with error $err") - } - ) - } - - private def getRadarrConfig(configReader: ConfigurationReader, client: HttpClient): IO[(Uri, String, Int, String)] = { - val url = configReader.getConfigOption(Keys.radarrBaseUrl).flatMap(Uri.fromString(_).toOption).getOrElse { - val default = "http://localhost:7878" - logger.warn(s"Unable to fetch radarr baseUrl, using default $default") - Uri.unsafeFromString(default) - } - val apiKey = configReader.getConfigOption(Keys.radarrApiKey).getOrElse(throwError("Unable to find radarr API key")) - - ArrUtils.getToArr(client)(url, apiKey, "rootFolder").map { - case Right(res) => - logger.info("Successfully connected to Radarr") - val allRootFolders = res.as[List[RootFolder]].getOrElse(List.empty) - selectRootFolder(allRootFolders, configReader.getConfigOption(Keys.radarrRootFolder)) - case Left(err) => - throwError(s"Unable to connect to Radarr at $url, with error $err") - }.flatMap(rootFolder => - ArrUtils.getToArr(client)(url, apiKey, "qualityprofile").map { - case Right(res) => - val allQualityProfiles = res.as[List[QualityProfile]].getOrElse(List.empty) - val chosenQualityProfile = configReader.getConfigOption(Keys.radarrQualityProfile) - (url, apiKey, getQualityProfileId(allQualityProfiles, chosenQualityProfile), rootFolder) - case Left(err) => - throwError(s"Unable to connect to Radarr at $url, with error $err") - } - ) - } - - private def getQualityProfileId(allProfiles: List[QualityProfile], maybeEnvVariable: Option[String]): Int = - (allProfiles, maybeEnvVariable) match { - case (Nil, _) => - throwError("Could not find any quality profiles defined, check your Sonarr/Radarr settings") - case (List(one), _) => - logger.debug(s"Only one quality profile defined: ${one.name}") - one.id - case (_, None) => - logger.debug("Multiple quality profiles found, selecting the first one in the list.") - allProfiles.head.id - case (_, Some(profileName)) => - allProfiles.find(_.name.toLowerCase == profileName.toLowerCase).map(_.id).getOrElse( - throwError(s"Unable to find quality profile $profileName. Possible values are $allProfiles") - ) - } - - private def selectRootFolder(allRootFolders: List[RootFolder], maybeEnvVariable: Option[String]): String = - (allRootFolders, maybeEnvVariable) match { - case (Nil, _) => - throwError("Could not find any root folders, check your Sonarr/Radarr settings") - case (_, Some(path)) => - allRootFolders.filter(_.accessible).find(r => normalizePath(r.path) == normalizePath(path)).map(_.path).getOrElse( - throwError(s"Unable to find root folder $path. Possible values are ${allRootFolders.filter(_.accessible).map(_.path)}") - ) - case (_, None) => - allRootFolders.find(_.accessible).map(_.path).getOrElse( - throwError("Found root folders, but they are not accessible by Sonarr/Radarr") - ) - } - - private def normalizePath(path: String): String = if (path.endsWith("/") && path.length > 1) path.dropRight(1) else path - - private def getPlexWatchlistUrls(configReader: ConfigurationReader): List[Uri] = - Set( - configReader.getConfigOption(Keys.plexWatchlist1), - configReader.getConfigOption(Keys.plexWatchlist2) - ).toList.collect { - case Some(url) => url - } match { - case Nil => - throwError("Missing plex watchlist URL") - case other => other.map(toPlexUri) - } - - private def toPlexUri(url: String): Uri = { - val supportedHosts = List( - "rss.plex.tv" - ).map(Uri.Host.unsafeFromString) - - val rawUri = Uri.fromString(url).getOrElse( - throwError(s"Plex watchlist $url is not a valid uri") - ) - - val host = rawUri.host.getOrElse( - throwError(s"Plex watchlist host not found in $rawUri") - ) - - if (!supportedHosts.contains(host)) - throwError(s"Unsupported Uri host on watchlist: ${rawUri.host}. Accepted hosts: $supportedHosts") - - rawUri - } - - private def throwError(message: String): Nothing = { - logger.error(message) - throw new IllegalArgumentException(message) - } -} \ No newline at end of file diff --git a/src/main/scala/configuration/ConfigurationReader.scala b/src/main/scala/configuration/ConfigurationReader.scala index 013b87c..08eaf52 100644 --- a/src/main/scala/configuration/ConfigurationReader.scala +++ b/src/main/scala/configuration/ConfigurationReader.scala @@ -3,7 +3,3 @@ package configuration trait ConfigurationReader { def getConfigOption(key: String): Option[String] } - -object SystemPropertyReader extends ConfigurationReader { - def getConfigOption(key: String): Option[String] = Option(System.getProperty(key)) -} diff --git a/src/main/scala/configuration/ConfigurationUtils.scala b/src/main/scala/configuration/ConfigurationUtils.scala new file mode 100644 index 0000000..acc521c --- /dev/null +++ b/src/main/scala/configuration/ConfigurationUtils.scala @@ -0,0 +1,168 @@ +package configuration + +import cats.effect.IO +import http.HttpClient +import io.circe.generic.auto._ +import io.circe.Json +import org.http4s.{Method, Uri} +import org.slf4j.LoggerFactory + +import scala.concurrent.duration._ + +object ConfigurationUtils { + + private val logger = LoggerFactory.getLogger(getClass) + + def create(configReader: ConfigurationReader, client: HttpClient): IO[Configuration] = + for { + sonarrConfig <- getSonarrConfig(configReader, client) + refreshInterval = configReader.getConfigOption(Keys.intervalSeconds).flatMap(_.toIntOption).getOrElse(60).seconds + (sonarrBaseUrl, sonarrApiKey, sonarrQualityProfileId, sonarrRootFolder) = sonarrConfig + sonarrBypassIgnored = configReader.getConfigOption(Keys.sonarrBypassIgnored).exists(_.toBoolean) + sonarrSeasonMonitoring = configReader.getConfigOption(Keys.sonarrSeasonMonitoring).getOrElse("all") + radarrConfig <- getRadarrConfig(configReader, client) + (radarrBaseUrl, radarrApiKey, radarrQualityProfileId, radarrRootFolder) = radarrConfig + radarrBypassIgnored = configReader.getConfigOption(Keys.radarrBypassIgnored).exists(_.toBoolean) + plexWatchlistUrls = getPlexWatchlistUrls(configReader) + } yield Configuration( + refreshInterval, + sonarrBaseUrl, + sonarrApiKey, + sonarrQualityProfileId, + sonarrRootFolder, + sonarrBypassIgnored, + sonarrSeasonMonitoring, + radarrBaseUrl, + radarrApiKey, + radarrQualityProfileId, + radarrRootFolder, + radarrBypassIgnored, + plexWatchlistUrls + ) + + private def getSonarrConfig(configReader: ConfigurationReader, client: HttpClient): IO[(Uri, String, Int, String)] = { + val url = configReader.getConfigOption(Keys.sonarrBaseUrl).flatMap(Uri.fromString(_).toOption).getOrElse { + val default = "http://localhost:8989" + logger.warn(s"Unable to fetch sonarr baseUrl, using default $default") + Uri.unsafeFromString(default) + } + val apiKey = configReader.getConfigOption(Keys.sonarrApiKey).getOrElse(throwError("Unable to find sonarr API key")) + + getToArr(client)(url, apiKey, "rootFolder").map { + case Right(res) => + logger.info("Successfully connected to Sonarr") + val allRootFolders = res.as[List[RootFolder]].getOrElse(List.empty) + selectRootFolder(allRootFolders, configReader.getConfigOption(Keys.sonarrRootFolder)) + case Left(err) => + throwError(s"Unable to connect to Sonarr at $url, with error $err") + }.flatMap(rootFolder => + getToArr(client)(url, apiKey, "qualityprofile").map { + case Right(res) => + val allQualityProfiles = res.as[List[QualityProfile]].getOrElse(List.empty) + val chosenQualityProfile = configReader.getConfigOption(Keys.sonarrQualityProfile) + (url, apiKey, getQualityProfileId(allQualityProfiles, chosenQualityProfile), rootFolder) + case Left(err) => + throwError(s"Unable to connect to Sonarr at $url, with error $err") + } + ) + } + + private def getRadarrConfig(configReader: ConfigurationReader, client: HttpClient): IO[(Uri, String, Int, String)] = { + val url = configReader.getConfigOption(Keys.radarrBaseUrl).flatMap(Uri.fromString(_).toOption).getOrElse { + val default = "http://localhost:7878" + logger.warn(s"Unable to fetch radarr baseUrl, using default $default") + Uri.unsafeFromString(default) + } + val apiKey = configReader.getConfigOption(Keys.radarrApiKey).getOrElse(throwError("Unable to find radarr API key")) + + getToArr(client)(url, apiKey, "rootFolder").map { + case Right(res) => + logger.info("Successfully connected to Radarr") + val allRootFolders = res.as[List[RootFolder]].getOrElse(List.empty) + selectRootFolder(allRootFolders, configReader.getConfigOption(Keys.radarrRootFolder)) + case Left(err) => + throwError(s"Unable to connect to Radarr at $url, with error $err") + }.flatMap(rootFolder => + getToArr(client)(url, apiKey, "qualityprofile").map { + case Right(res) => + val allQualityProfiles = res.as[List[QualityProfile]].getOrElse(List.empty) + val chosenQualityProfile = configReader.getConfigOption(Keys.radarrQualityProfile) + (url, apiKey, getQualityProfileId(allQualityProfiles, chosenQualityProfile), rootFolder) + case Left(err) => + throwError(s"Unable to connect to Radarr at $url, with error $err") + } + ) + } + + private def getQualityProfileId(allProfiles: List[QualityProfile], maybeEnvVariable: Option[String]): Int = + (allProfiles, maybeEnvVariable) match { + case (Nil, _) => + throwError("Could not find any quality profiles defined, check your Sonarr/Radarr settings") + case (List(one), _) => + logger.debug(s"Only one quality profile defined: ${one.name}") + one.id + case (_, None) => + logger.debug("Multiple quality profiles found, selecting the first one in the list.") + allProfiles.head.id + case (_, Some(profileName)) => + allProfiles.find(_.name.toLowerCase == profileName.toLowerCase).map(_.id).getOrElse( + throwError(s"Unable to find quality profile $profileName. Possible values are $allProfiles") + ) + } + + private def selectRootFolder(allRootFolders: List[RootFolder], maybeEnvVariable: Option[String]): String = + (allRootFolders, maybeEnvVariable) match { + case (Nil, _) => + throwError("Could not find any root folders, check your Sonarr/Radarr settings") + case (_, Some(path)) => + allRootFolders.filter(_.accessible).find(r => normalizePath(r.path) == normalizePath(path)).map(_.path).getOrElse( + throwError(s"Unable to find root folder $path. Possible values are ${allRootFolders.filter(_.accessible).map(_.path)}") + ) + case (_, None) => + allRootFolders.find(_.accessible).map(_.path).getOrElse( + throwError("Found root folders, but they are not accessible by Sonarr/Radarr") + ) + } + + private def normalizePath(path: String): String = if (path.endsWith("/") && path.length > 1) path.dropRight(1) else path + + private def getPlexWatchlistUrls(configReader: ConfigurationReader): List[Uri] = + Set( + configReader.getConfigOption(Keys.plexWatchlist1), + configReader.getConfigOption(Keys.plexWatchlist2) + ).toList.collect { + case Some(url) => url + } match { + case Nil => + throwError("Missing plex watchlist URL") + case other => other.map(toPlexUri) + } + + private def toPlexUri(url: String): Uri = { + val supportedHosts = List( + "rss.plex.tv" + ).map(Uri.Host.unsafeFromString) + + val rawUri = Uri.fromString(url).getOrElse( + throwError(s"Plex watchlist $url is not a valid uri") + ) + + val host = rawUri.host.getOrElse( + throwError(s"Plex watchlist host not found in $rawUri") + ) + + if (!supportedHosts.contains(host)) + throwError(s"Unsupported Uri host on watchlist: ${rawUri.host}. Accepted hosts: $supportedHosts") + + rawUri + } + + private def throwError(message: String): Nothing = { + logger.error(message) + throw new IllegalArgumentException(message) + } + + private def getToArr(client: HttpClient)(baseUrl: Uri, apiKey: String, endpoint: String): IO[Either[Throwable, Json]] = + client.httpRequest(Method.GET, baseUrl / "api" / "v3" / endpoint, Some(apiKey)) + +} diff --git a/src/main/scala/configuration/Keys.scala b/src/main/scala/configuration/Keys.scala index 57b028e..432184d 100644 --- a/src/main/scala/configuration/Keys.scala +++ b/src/main/scala/configuration/Keys.scala @@ -1,6 +1,6 @@ package configuration -object Keys { +private[configuration] object Keys { val intervalSeconds = "interval.seconds" val sonarrBaseUrl = "sonarr.baseUrl" diff --git a/src/main/scala/configuration/QualityProfile.scala b/src/main/scala/configuration/QualityProfile.scala new file mode 100644 index 0000000..977ea07 --- /dev/null +++ b/src/main/scala/configuration/QualityProfile.scala @@ -0,0 +1,3 @@ +package configuration + +private[configuration] case class QualityProfile(name: String, id: Int) diff --git a/src/main/scala/configuration/RootFolder.scala b/src/main/scala/configuration/RootFolder.scala new file mode 100644 index 0000000..d674333 --- /dev/null +++ b/src/main/scala/configuration/RootFolder.scala @@ -0,0 +1,3 @@ +package configuration + +private[configuration] case class RootFolder(path: String, accessible: Boolean) diff --git a/src/main/scala/configuration/SystemPropertyReader.scala b/src/main/scala/configuration/SystemPropertyReader.scala new file mode 100644 index 0000000..2c48896 --- /dev/null +++ b/src/main/scala/configuration/SystemPropertyReader.scala @@ -0,0 +1,5 @@ +package configuration + +object SystemPropertyReader extends ConfigurationReader { + def getConfigOption(key: String): Option[String] = Option(System.getProperty(key)) +} diff --git a/src/main/scala/utils/HttpClient.scala b/src/main/scala/http/HttpClient.scala similarity index 98% rename from src/main/scala/utils/HttpClient.scala rename to src/main/scala/http/HttpClient.scala index 79b852d..a046e39 100644 --- a/src/main/scala/utils/HttpClient.scala +++ b/src/main/scala/http/HttpClient.scala @@ -1,11 +1,11 @@ -package utils +package http import cats.effect.IO import io.circe.Json -import org.http4s.{Header, Method, Request, Uri} -import org.typelevel.ci.CIString import org.http4s.circe._ import org.http4s.ember.client.EmberClientBuilder +import org.http4s.{Header, Method, Request, Uri} +import org.typelevel.ci.CIString class HttpClient { diff --git a/src/main/scala/model/Item.scala b/src/main/scala/model/Item.scala index 859af54..3b9ee43 100644 --- a/src/main/scala/model/Item.scala +++ b/src/main/scala/model/Item.scala @@ -1,3 +1,17 @@ package model -case class Item(title: String, guids: List[String], category: String) +case class Item(title: String, guids: List[String], category: String) { + def getTvdbId: Option[Long] = + guids.find(_.startsWith("tvdb://")).flatMap(_.stripPrefix("tvdb://").toLongOption) + + def getTmdbId: Option[Long] = + guids.find(_.startsWith("tmdb://")).flatMap(_.stripPrefix("tmdb://").toLongOption) + + def matches(that: Any): Boolean = that match { + case Item(_, theirGuids, c) if c == this.category => + theirGuids.foldLeft(false) { + case (acc, guid) => acc || guids.contains(guid) + } + case _ => false + } +} diff --git a/src/main/scala/model/QualityProfile.scala b/src/main/scala/model/QualityProfile.scala deleted file mode 100644 index b22b7d7..0000000 --- a/src/main/scala/model/QualityProfile.scala +++ /dev/null @@ -1,3 +0,0 @@ -package model - -case class QualityProfile(name: String, id: Int) diff --git a/src/main/scala/model/RadarrMovie.scala b/src/main/scala/model/RadarrMovie.scala deleted file mode 100644 index ceec55d..0000000 --- a/src/main/scala/model/RadarrMovie.scala +++ /dev/null @@ -1,7 +0,0 @@ -package model - -case class RadarrMovie(title: String, imdbId: Option[String], tmdbId: Option[Long]) - -case class RadarrMovieExclusion(movieTitle: String, imdbId: Option[String], tmdbId: Option[Long]) { - def toRadarrMovie: RadarrMovie = RadarrMovie(this.movieTitle, this.imdbId, this.tmdbId) -} diff --git a/src/main/scala/model/RootFolder.scala b/src/main/scala/model/RootFolder.scala deleted file mode 100644 index 8c0f0db..0000000 --- a/src/main/scala/model/RootFolder.scala +++ /dev/null @@ -1,3 +0,0 @@ -package model - -case class RootFolder(path: String, accessible: Boolean) diff --git a/src/main/scala/model/SonarrSeries.scala b/src/main/scala/model/SonarrSeries.scala deleted file mode 100644 index 1dc79e9..0000000 --- a/src/main/scala/model/SonarrSeries.scala +++ /dev/null @@ -1,3 +0,0 @@ -package model - -case class SonarrSeries(title: String, imdbId: Option[String], tvdbId: Option[Long]) diff --git a/src/main/scala/model/Watchlist.scala b/src/main/scala/model/Watchlist.scala deleted file mode 100644 index c1d05a7..0000000 --- a/src/main/scala/model/Watchlist.scala +++ /dev/null @@ -1,3 +0,0 @@ -package model - -case class Watchlist(items: Set[Item]) diff --git a/src/main/scala/plex/PlexUtils.scala b/src/main/scala/plex/PlexUtils.scala new file mode 100644 index 0000000..a378e1c --- /dev/null +++ b/src/main/scala/plex/PlexUtils.scala @@ -0,0 +1,26 @@ +package plex + +import io.circe.generic.auto._ +import cats.effect.IO +import http.HttpClient +import model.Item +import org.http4s.{Method, Uri} +import org.slf4j.LoggerFactory + +trait PlexUtils { + + private val logger = LoggerFactory.getLogger(getClass) + + protected def fetchWatchlistFromRss(client: HttpClient)(url: Uri): IO[Set[Item]] = + client.httpRequest(Method.GET, url).map { + case Left(err) => + logger.warn(s"Unable to fetch watchlist from Plex: $err") + Set.empty + case Right(json) => + logger.debug("Found Json from Plex watchlist, attempting to decode") + json.as[Watchlist].map(_.items).getOrElse { + logger.warn("Unable to fetch watchlist from Plex - decoding failure. Returning empty list instead") + Set.empty + } + } +} diff --git a/src/main/scala/plex/Watchlist.scala b/src/main/scala/plex/Watchlist.scala new file mode 100644 index 0000000..facbe7b --- /dev/null +++ b/src/main/scala/plex/Watchlist.scala @@ -0,0 +1,5 @@ +package plex + +import model.Item + +private[plex] case class Watchlist(items: Set[Item]) diff --git a/src/main/scala/radarr/AddOptions.scala b/src/main/scala/radarr/AddOptions.scala new file mode 100644 index 0000000..5af6002 --- /dev/null +++ b/src/main/scala/radarr/AddOptions.scala @@ -0,0 +1,3 @@ +package radarr + +private case class AddOptions(searchForMovie: Boolean = true) diff --git a/src/main/scala/radarr/RadarrConversions.scala b/src/main/scala/radarr/RadarrConversions.scala new file mode 100644 index 0000000..662bfd0 --- /dev/null +++ b/src/main/scala/radarr/RadarrConversions.scala @@ -0,0 +1,13 @@ +package radarr + +import model.Item + +private[radarr] trait RadarrConversions { + def toItem(movie: RadarrMovie): Item = Item( + movie.title, + List(movie.imdbId, movie.tmdbId.map("tmdb://" + _)).collect { case Some(x) => x }, + "movie" + ) + + def toItem(movie: RadarrMovieExclusion): Item = toItem(RadarrMovie(movie.movieTitle, movie.imdbId, movie.tmdbId)) +} diff --git a/src/main/scala/radarr/RadarrMovie.scala b/src/main/scala/radarr/RadarrMovie.scala new file mode 100644 index 0000000..b14c310 --- /dev/null +++ b/src/main/scala/radarr/RadarrMovie.scala @@ -0,0 +1,3 @@ +package radarr + +private[radarr] case class RadarrMovie(title: String, imdbId: Option[String], tmdbId: Option[Long]) diff --git a/src/main/scala/radarr/RadarrMovieExclusion.scala b/src/main/scala/radarr/RadarrMovieExclusion.scala new file mode 100644 index 0000000..4abd124 --- /dev/null +++ b/src/main/scala/radarr/RadarrMovieExclusion.scala @@ -0,0 +1,3 @@ +package radarr + +private[radarr] case class RadarrMovieExclusion(movieTitle: String, imdbId: Option[String], tmdbId: Option[Long]) diff --git a/src/main/scala/radarr/RadarrPost.scala b/src/main/scala/radarr/RadarrPost.scala new file mode 100644 index 0000000..e46322f --- /dev/null +++ b/src/main/scala/radarr/RadarrPost.scala @@ -0,0 +1,3 @@ +package radarr + +private case class RadarrPost(title: String, tmdbId: Long, qualityProfileId: Int = 6, rootFolderPath: String, addOptions: AddOptions = AddOptions()) diff --git a/src/main/scala/radarr/RadarrUtils.scala b/src/main/scala/radarr/RadarrUtils.scala new file mode 100644 index 0000000..0b3ea1c --- /dev/null +++ b/src/main/scala/radarr/RadarrUtils.scala @@ -0,0 +1,62 @@ +package radarr + +import cats.effect.IO +import configuration.Configuration +import http.HttpClient +import io.circe.Json +import io.circe.generic.auto._ +import io.circe.syntax.EncoderOps +import model.Item +import org.http4s.{Method, Uri} +import org.slf4j.LoggerFactory + +trait RadarrUtils extends RadarrConversions { + + private val logger = LoggerFactory.getLogger(getClass) + + protected def fetchMovies(client: HttpClient)(apiKey: String, baseUrl: Uri, bypass: Boolean): IO[Set[Item]] = + for { + movies <- getToArr(client)(baseUrl, apiKey, "movie").map { + case Right(res) => + res.as[List[RadarrMovie]].getOrElse { + logger.warn("Unable to fetch movies from Radarr - decoding failure. Returning empty list instead") + List.empty + } + case Left(err) => + logger.warn(s"Received error while trying to fetch movies from Radarr: $err") + List.empty + } + exclusions <- if (bypass) { + IO.pure(List.empty) + } else { + getToArr(client)(baseUrl, apiKey, "exclusions").map { + case Right(res) => + res.as[List[RadarrMovieExclusion]].getOrElse { + logger.warn("Unable to fetch movie exclusions from Radarr - decoding failure. Returning empty list instead") + List.empty + } + case Left(err) => + logger.warn(s"Received error while trying to fetch movie exclusions from Radarr: $err") + List.empty + } + } + } yield (movies.map(toItem) ++ exclusions.map(toItem)).toSet + + protected def addToRadarr(client: HttpClient)(config: Configuration)(item: Item): IO[Unit] = { + + val movie = RadarrPost(item.title, item.getTmdbId.getOrElse(0L), config.radarrQualityProfileId, config.radarrRootFolder) + + postToArr(client)(config.radarrBaseUrl, config.radarrApiKey, "movie")(movie.asJson).map { + case Right(_) => + logger.info(s"Successfully added movie ${item.title} to Radarr") + case Left(err) => + logger.error(s"Failed to add movie ${item.title}: $err") + } + } + + private def getToArr(client: HttpClient)(baseUrl: Uri, apiKey: String, endpoint: String): IO[Either[Throwable, Json]] = + client.httpRequest(Method.GET, baseUrl / "api" / "v3" / endpoint, Some(apiKey)) + + private def postToArr(client: HttpClient)(baseUrl: Uri, apiKey: String, endpoint: String)(payload: Json): IO[Either[Throwable, Json]] = + client.httpRequest(Method.POST, baseUrl / "api" / "v3" / endpoint, Some(apiKey), Some(payload)) +} diff --git a/src/main/scala/sonarr/SonarrAddOptions.scala b/src/main/scala/sonarr/SonarrAddOptions.scala new file mode 100644 index 0000000..1a79927 --- /dev/null +++ b/src/main/scala/sonarr/SonarrAddOptions.scala @@ -0,0 +1,3 @@ +package sonarr + +private[sonarr] case class SonarrAddOptions(monitor: String, searchForCutoffUnmetEpisodes: Boolean = true, searchForMissingEpisodes: Boolean = true) diff --git a/src/main/scala/sonarr/SonarrConversions.scala b/src/main/scala/sonarr/SonarrConversions.scala new file mode 100644 index 0000000..c85abd4 --- /dev/null +++ b/src/main/scala/sonarr/SonarrConversions.scala @@ -0,0 +1,11 @@ +package sonarr + +import model.Item + +private[sonarr] trait SonarrConversions { + def toItem(series: SonarrSeries): Item = Item( + series.title, + List(series.imdbId, series.tvdbId.map("tvdb://" + _)).collect { case Some(x) => x }, + "show" + ) +} diff --git a/src/main/scala/sonarr/SonarrPost.scala b/src/main/scala/sonarr/SonarrPost.scala new file mode 100644 index 0000000..c7f298e --- /dev/null +++ b/src/main/scala/sonarr/SonarrPost.scala @@ -0,0 +1,3 @@ +package sonarr + +private[sonarr] case class SonarrPost(title: String, tvdbId: Long, qualityProfileId: Int, rootFolderPath: String, addOptions: SonarrAddOptions, monitored: Boolean = true) diff --git a/src/main/scala/sonarr/SonarrSeries.scala b/src/main/scala/sonarr/SonarrSeries.scala new file mode 100644 index 0000000..2eff82a --- /dev/null +++ b/src/main/scala/sonarr/SonarrSeries.scala @@ -0,0 +1,3 @@ +package sonarr + +private[sonarr] case class SonarrSeries(title: String, imdbId: Option[String], tvdbId: Option[Long]) diff --git a/src/main/scala/sonarr/SonarrUtils.scala b/src/main/scala/sonarr/SonarrUtils.scala new file mode 100644 index 0000000..60e2fbf --- /dev/null +++ b/src/main/scala/sonarr/SonarrUtils.scala @@ -0,0 +1,63 @@ +package sonarr + +import cats.effect.IO +import configuration.Configuration +import http.HttpClient +import io.circe.Json +import io.circe.generic.auto._ +import io.circe.syntax.EncoderOps +import model.Item +import org.http4s.{Method, Uri} +import org.slf4j.LoggerFactory + +trait SonarrUtils extends SonarrConversions { + + private val logger = LoggerFactory.getLogger(getClass) + + protected def fetchSeries(client: HttpClient)(apiKey: String, baseUrl: Uri, bypass: Boolean): IO[Set[Item]] = + for { + shows <- getToArr(client)(baseUrl, apiKey, "series").map { + case Right(res) => + res.as[List[SonarrSeries]].getOrElse { + logger.warn("Unable to fetch series from Sonarr - decoding failure. Returning empty list instead") + List.empty + } + case Left(err) => + logger.warn(s"Received error while trying to fetch movies from Radarr: $err") + List.empty + } + exclusions <- if (bypass) { + IO.pure(List.empty) + } else { + getToArr(client)(baseUrl, apiKey, "importlistexclusion").map { + case Right(res) => + res.as[List[SonarrSeries]].getOrElse { + logger.warn("Unable to fetch show exclusions from Sonarr - decoding failure. Returning empty list instead") + List.empty + } + case Left(err) => + logger.warn(s"Received error while trying to fetch show exclusions from Sonarr: $err") + List.empty + } + } + } yield (shows.map(toItem) ++ exclusions.map(toItem)).toSet + + protected def addToSonarr(client: HttpClient)(config: Configuration)(item: Item): IO[Unit] = { + + val addOptions = SonarrAddOptions(config.sonarrSeasonMonitoring) + val show = SonarrPost(item.title, item.getTvdbId.getOrElse(0L), config.sonarrQualityProfileId, config.radarrRootFolder, addOptions) + + postToArr(client)(config.sonarrBaseUrl, config.sonarrApiKey, "series")(show.asJson).map { + case Right(_) => + logger.info(s"Successfully added show ${item.title} to Sonarr") + case Left(err) => + logger.info(s"Failed to add show ${item.title}: $err") + } + } + + private def getToArr(client: HttpClient)(baseUrl: Uri, apiKey: String, endpoint: String): IO[Either[Throwable, Json]] = + client.httpRequest(Method.GET, baseUrl / "api" / "v3" / endpoint, Some(apiKey)) + + private def postToArr(client: HttpClient)(baseUrl: Uri, apiKey: String, endpoint: String)(payload: Json): IO[Either[Throwable, Json]] = + client.httpRequest(Method.POST, baseUrl / "api" / "v3" / endpoint, Some(apiKey), Some(payload)) +} diff --git a/src/main/scala/utils/ArrUtils.scala b/src/main/scala/utils/ArrUtils.scala deleted file mode 100644 index b7ad1ed..0000000 --- a/src/main/scala/utils/ArrUtils.scala +++ /dev/null @@ -1,15 +0,0 @@ -package utils - -import cats.effect.IO -import io.circe.Json -import org.http4s.{Method, Uri} - -object ArrUtils { - - def getToArr(client: HttpClient)(baseUrl: Uri, apiKey: String, endpoint: String): IO[Either[Throwable, Json]] = - client.httpRequest(Method.GET, baseUrl / "api" / "v3" / endpoint, Some(apiKey)) - - def postToArr(client: HttpClient)(baseUrl: Uri, apiKey: String, endpoint: String)(payload: Json): IO[Either[Throwable, Json]] = - client.httpRequest(Method.POST, baseUrl / "api" / "v3" / endpoint, Some(apiKey), Some(payload)) - -} diff --git a/src/test/scala/WatchlistSyncSpec.scala b/src/test/scala/WatchlistSyncSpec.scala deleted file mode 100644 index 803a2c7..0000000 --- a/src/test/scala/WatchlistSyncSpec.scala +++ /dev/null @@ -1,80 +0,0 @@ -import io.circe.generic.auto._ -import io.circe.parser.decode -import model._ -import org.scalatest.flatspec.AnyFlatSpec -import org.scalatest.matchers.should.Matchers - -import scala.io.Source - -class WatchlistSyncSpec extends AnyFlatSpec with Matchers { - - "A WatchlistSync" should "correctly deserialize movies" in { - val jsonStr = Source.fromResource("radarr.json").getLines().mkString("\n") - - val decodedMovies = decode[List[RadarrMovie]](jsonStr) - - decodedMovies match { - case Right(movies) => - movies should not be empty - movies.head should be (RadarrMovie("Incredible But True", Some("tt13145534"), Some(735697))) - case Left(error) => - fail(s"Failed to decode JSON: $error") - } - } - - it should "correctly deserialize series" in { - val jsonStr = Source.fromResource("sonarr.json").getLines().mkString("\n") - - val decodedSeries = decode[List[SonarrSeries]](jsonStr) - - decodedSeries match { - case Right(series) => - series should not be empty - series.head should be (SonarrSeries("Three-Body", Some("tt20242042"), Some(421555))) - case Left(error) => - fail(s"Failed to decode JSON: $error") - } - } - - it should "correctly deserialize watchlist" in { - val jsonStr = Source.fromResource("watchlist.json").getLines().mkString("\n") - - val decodedWatchlist = decode[Watchlist](jsonStr) - - decodedWatchlist match { - case Right(Watchlist(items)) => - items should not be empty - items.head should be (Item("Enola Holmes 2 (2022)", List("imdb://tt14641788", "tmdb://829280", "tvdb://166087"), "movie")) - case Left(error) => - fail(s"Failed to decode JSON: $error") - } - } - - it should "correctly deserialize Sonarr exclusions" in { - val jsonStr = Source.fromResource("importlistexclusion.json").getLines().mkString("\n") - - val decodedExclusions = decode[List[SonarrSeries]](jsonStr) - - decodedExclusions match { - case Right(exclusions) => - exclusions should not be empty - exclusions.head should be (SonarrSeries("The Test", None, Some(372848))) - case Left(error) => - fail(s"Failed to decode JSON: $error") - } - } - - it should "correctly deserialize Radarr exclusions" in { - val jsonStr = Source.fromResource("exclusions.json").getLines().mkString("\n") - - val decodedExclusions = decode[List[RadarrMovieExclusion]](jsonStr) - - decodedExclusions match { - case Right(exclusions) => - exclusions should not be empty - exclusions.head.toRadarrMovie should be (RadarrMovie("Test", None, Some(226979))) - case Left(error) => - fail(s"Failed to decode JSON: $error") - } - } -} diff --git a/src/test/scala/configuration/ConfigurationSpec.scala b/src/test/scala/configuration/ConfigurationUtilsSpec.scala similarity index 97% rename from src/test/scala/configuration/ConfigurationSpec.scala rename to src/test/scala/configuration/ConfigurationUtilsSpec.scala index 05edcab..1988087 100644 --- a/src/test/scala/configuration/ConfigurationSpec.scala +++ b/src/test/scala/configuration/ConfigurationUtilsSpec.scala @@ -2,20 +2,19 @@ package configuration import cats.effect.IO import cats.effect.unsafe.implicits.global -import model.QualityProfile +import http.HttpClient import org.http4s.{Method, Uri} import org.scalamock.scalatest.MockFactory import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers -import utils.HttpClient import io.circe.generic.auto._ import io.circe.parser._ import io.circe.syntax.EncoderOps import scala.io.Source -class ConfigurationSpec extends AnyFlatSpec with Matchers with MockFactory { - "A configuration.Configuration" should "start with all required values provided" in { +class ConfigurationUtilsSpec extends AnyFlatSpec with Matchers with MockFactory { + "A model.Configuration" should "start with all required values provided" in { val mockConfigReader = createMockConfigReader() val mockHttpClient = createMockHttpClient() diff --git a/src/test/scala/model/ItemSpec.scala b/src/test/scala/model/ItemSpec.scala new file mode 100644 index 0000000..f68dec3 --- /dev/null +++ b/src/test/scala/model/ItemSpec.scala @@ -0,0 +1,91 @@ +package model + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import java.util.UUID + +class ItemSpec extends AnyFlatSpec with Matchers { + "Item.matches" should "match to another identical item" in { + val item1 = Item(punch, List(punch, punch), punch) + val item2 = item1 + + item1.matches(item2) shouldBe true + item2.matches(item1) shouldBe true + } + + it should "ignore the title" in { + val item1 = Item(punch, List(punch, punch), punch) + val item2 = item1.copy(title = punch) + + item1.matches(item2) shouldBe true + item2.matches(item1) shouldBe true + } + + it should "fail if the categories do not match" in { + val item1 = Item(punch, List(punch, punch), punch) + val item2 = item1.copy(category = punch) + + item1.matches(item2) shouldBe false + item2.matches(item1) shouldBe false + } + + it should "succeed if one of the guids match" in { + val item1 = Item(punch, List(punch, punch), punch) + val item2 = item1.copy(guids = List(item1.guids.head)) + val item3 = item1.copy(guids = List(item1.guids.last)) + + item1.matches(item2) shouldBe true + item2.matches(item1) shouldBe true + item1.matches(item3) shouldBe true + item3.matches(item1) shouldBe true + item2.matches(item3) shouldBe false + item3.matches(item2) shouldBe false + } + + it should "fail if the guids do not match" in { + val item1 = Item(punch, List(punch, punch), punch) + val item2 = item1.copy(guids = List(punch)) + + item1.matches(item2) shouldBe false + item2.matches(item1) shouldBe false + } + + "Item.getTvdbId" should "return a tvdbId" in { + val item = Item(punch, List(punch, "tvdb://12345"), punch) + + item.getTvdbId shouldBe Some(12345L) + } + + it should "return none if there is no tvdbId" in { + val item = Item(punch, List(punch, "tmdb://12345"), punch) + + item.getTvdbId shouldBe None + } + + it should "return none if there is a badly formatted tvdbId" in { + val item = Item(punch, List(punch, "tvdb://l2345"), punch) + + item.getTvdbId shouldBe None + } + + "Item.getTmdbId" should "return a tmdbId" in { + val item = Item(punch, List(punch, "tmdb://12345"), punch) + + item.getTmdbId shouldBe Some(12345L) + } + + it should "return none if there is no tmdbId" in { + val item = Item(punch, List(punch, "tvdb://12345"), punch) + + item.getTmdbId shouldBe None + } + + it should "return none if there is a badly formatted tmdbId" in { + val item = Item(punch, List(punch, "tmdb://l2345"), punch) + + item.getTmdbId shouldBe None + } + + private def punch: String = UUID.randomUUID().toString.take(5) +} diff --git a/src/test/scala/plex/PlexUtilsSpec.scala b/src/test/scala/plex/PlexUtilsSpec.scala new file mode 100644 index 0000000..058713f --- /dev/null +++ b/src/test/scala/plex/PlexUtilsSpec.scala @@ -0,0 +1,47 @@ +package plex + +import cats.effect.IO +import http.HttpClient +import io.circe.parser._ +import model.Item +import org.http4s.{Method, Uri} +import org.scalamock.scalatest.MockFactory +import cats.effect.unsafe.implicits.global +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import scala.io.Source + +class PlexUtilsSpec extends AnyFlatSpec with Matchers with PlexUtils with MockFactory { + + "PlexUtils" should "successfully fetch a watchlist from RSS feeds" in { + val watchlistStr = Source.fromResource("watchlist.json").getLines().mkString("\n") + val mockClient = mock[HttpClient] + (mockClient.httpRequest _).expects( + Method.GET, + Uri.unsafeFromString("http://localhost:9090"), + None, + None + ).returning(IO.pure(parse(watchlistStr))).once() + + val result = fetchWatchlistFromRss(mockClient)(Uri.unsafeFromString("http://localhost:9090")).unsafeRunSync() + + result.size shouldBe 7 + result.head shouldBe Item("Enola Holmes 2 (2022)", List("imdb://tt14641788", "tmdb://829280", "tvdb://166087"), "movie") + result.last shouldBe Item("The Wheel of Time (2021)", List("imdb://tt7462410", "tmdb://71914", "tvdb://355730"), "show") + } + + it should "not fail when the list returned is empty" in { + val mockClient = mock[HttpClient] + (mockClient.httpRequest _).expects( + Method.GET, + Uri.unsafeFromString("http://localhost:9090"), + None, + None + ).returning(IO.pure(parse("{}"))).once() + + val result = fetchWatchlistFromRss(mockClient)(Uri.unsafeFromString("http://localhost:9090")).unsafeRunSync() + + result.size shouldBe 0 + } +} diff --git a/src/test/scala/radarr/RadarrUtilsSpec.scala b/src/test/scala/radarr/RadarrUtilsSpec.scala new file mode 100644 index 0000000..53f1bcb --- /dev/null +++ b/src/test/scala/radarr/RadarrUtilsSpec.scala @@ -0,0 +1,62 @@ +package radarr + +import cats.effect.IO +import http.HttpClient +import cats.effect.unsafe.implicits.global +import io.circe.parser._ +import model.Item +import org.http4s.{Method, Uri} +import org.scalamock.scalatest.MockFactory +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import scala.io.Source + +class RadarrUtilsSpec extends AnyFlatSpec with Matchers with RadarrUtils with MockFactory { + + "RadarrUtils" should "successfully fetch a list of movies and exclusions from Radarr" in { + val movieJsonStr = Source.fromResource("radarr.json").getLines().mkString("\n") + val exclusionsJsonStr = Source.fromResource("exclusions.json").getLines().mkString("\n") + val mockClient = mock[HttpClient] + (mockClient.httpRequest _).expects( + Method.GET, + Uri.unsafeFromString("http://localhost:7878").withPath(Uri.Path.unsafeFromString("/api/v3/movie")), + Some("radarr-api-key"), + None + ).returning(IO.pure(parse(movieJsonStr))).once() + (mockClient.httpRequest _).expects( + Method.GET, + Uri.unsafeFromString("http://localhost:7878").withPath(Uri.Path.unsafeFromString("/api/v3/exclusions")), + Some("radarr-api-key"), + None + ).returning(IO.pure(parse(exclusionsJsonStr))).once() + + val result = fetchMovies(mockClient)("radarr-api-key", Uri.unsafeFromString("http://localhost:7878"), false).unsafeRunSync() + + result.size shouldBe 157 + result.head shouldBe Item("Judy", List("tt7549996", "tmdb://491283"), "movie") + result.last shouldBe Item("Ghosted", List("tt15326988", "tmdb://868759"), "movie") + // Check that exclusions are added + result.find(_.title == "Monty Python and the Holy Grail") shouldBe Some(Item("Monty Python and the Holy Grail", List("tmdb://762"), "movie")) + } + + it should "not fail when the list returned is empty" in { + val mockClient = mock[HttpClient] + (mockClient.httpRequest _).expects( + Method.GET, + Uri.unsafeFromString("http://localhost:7878").withPath(Uri.Path.unsafeFromString("/api/v3/movie")), + Some("radarr-api-key"), + None + ).returning(IO.pure(parse("[]"))).once() + (mockClient.httpRequest _).expects( + Method.GET, + Uri.unsafeFromString("http://localhost:7878").withPath(Uri.Path.unsafeFromString("/api/v3/exclusions")), + Some("radarr-api-key"), + None + ).returning(IO.pure(parse("[]"))).once() + + val result = fetchMovies(mockClient)("radarr-api-key", Uri.unsafeFromString("http://localhost:7878"), false).unsafeRunSync() + + result.size shouldBe 0 + } +} diff --git a/src/test/scala/sonarr/SonarrUtilsSpec.scala b/src/test/scala/sonarr/SonarrUtilsSpec.scala new file mode 100644 index 0000000..7edd4dc --- /dev/null +++ b/src/test/scala/sonarr/SonarrUtilsSpec.scala @@ -0,0 +1,62 @@ +package sonarr + +import cats.effect.IO +import http.HttpClient +import cats.effect.unsafe.implicits.global +import io.circe.parser._ +import model.Item +import org.http4s.{Method, Uri} +import org.scalamock.scalatest.MockFactory +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import scala.io.Source + +class SonarrUtilsSpec extends AnyFlatSpec with Matchers with SonarrUtils with MockFactory { + + "SonarrUtils" should "successfully fetch a list of series and exclusions from Sonarr" in { + val seriesJsonStr = Source.fromResource("sonarr.json").getLines().mkString("\n") + val exclusionsJsonStr = Source.fromResource("importlistexclusion.json").getLines().mkString("\n") + val mockClient = mock[HttpClient] + (mockClient.httpRequest _).expects( + Method.GET, + Uri.unsafeFromString("http://localhost:8989").withPath(Uri.Path.unsafeFromString("/api/v3/series")), + Some("sonarr-api-key"), + None + ).returning(IO.pure(parse(seriesJsonStr))).once() + (mockClient.httpRequest _).expects( + Method.GET, + Uri.unsafeFromString("http://localhost:8989").withPath(Uri.Path.unsafeFromString("/api/v3/importlistexclusion")), + Some("sonarr-api-key"), + None + ).returning(IO.pure(parse(exclusionsJsonStr))).once() + + val result = fetchSeries(mockClient)("sonarr-api-key", Uri.unsafeFromString("http://localhost:8989"), false).unsafeRunSync() + + result.size shouldBe 76 + result.head shouldBe Item("The Secret Life of 4, 5 and 6 Year Olds", List("tt6620876", "tvdb://304746"), "show") + result.last shouldBe Item("Maternal", List("tt21636214", "tvdb://424724"), "show") + // Check that exclusions are added + result.find(_.title == "The Test") shouldBe Some(Item("The Test", List("tvdb://372848"), "show")) + } + + it should "not fail when the list returned is empty" in { + val mockClient = mock[HttpClient] + (mockClient.httpRequest _).expects( + Method.GET, + Uri.unsafeFromString("http://localhost:8989").withPath(Uri.Path.unsafeFromString("/api/v3/series")), + Some("sonarr-api-key"), + None + ).returning(IO.pure(parse("[]"))).once() + (mockClient.httpRequest _).expects( + Method.GET, + Uri.unsafeFromString("http://localhost:8989").withPath(Uri.Path.unsafeFromString("/api/v3/importlistexclusion")), + Some("sonarr-api-key"), + None + ).returning(IO.pure(parse("[]"))).once() + + val result = fetchSeries(mockClient)("sonarr-api-key", Uri.unsafeFromString("http://localhost:8989"), false).unsafeRunSync() + + result.size shouldBe 0 + } +} From 3c91c903cfc88078a3454222a322d24c163cf69a Mon Sep 17 00:00:00 2001 From: Nihal Mirpuri Date: Thu, 16 Nov 2023 21:37:40 +0000 Subject: [PATCH 2/2] small renaming of test --- src/test/scala/configuration/ConfigurationUtilsSpec.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/scala/configuration/ConfigurationUtilsSpec.scala b/src/test/scala/configuration/ConfigurationUtilsSpec.scala index 1988087..237e47d 100644 --- a/src/test/scala/configuration/ConfigurationUtilsSpec.scala +++ b/src/test/scala/configuration/ConfigurationUtilsSpec.scala @@ -14,7 +14,7 @@ import io.circe.syntax.EncoderOps import scala.io.Source class ConfigurationUtilsSpec extends AnyFlatSpec with Matchers with MockFactory { - "A model.Configuration" should "start with all required values provided" in { + "ConfigurationUtils.create" should "start with all required values provided" in { val mockConfigReader = createMockConfigReader() val mockHttpClient = createMockHttpClient()