diff --git a/README.md b/README.md index d5ed92c..ebfeb21 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,15 @@ Sync plex watchlists in realtime with Sonarr and Radarr. **Requires Plex Pass** +## Why? +### Sonarr/Radarr limitation +While Sonarr and Radarr have built-in functionality (in v4 and v3 respectively), they are set at 6 hour refresh intervals, and cannot be customized + +### Ombi/Overseer limitation +While Ombi and Overseer have built-in functionality, there are two problems with this: + * They are customizable down to 5 minute intervals, so doesn't allow the "real-time" sync that Watchlistarr does + * They rely on Plex tokens, which expire and break the sync if you're not regularly logging into Ombi/Overseer + ## Getting Started The easiest way to try this code is using docker: @@ -23,10 +32,12 @@ docker run \ | SONARR_BASE_URL | http://localhost:8989 | Yes | Base URL for Sonarr, including the 'http' and port | | SONARR_QUALITY_PROFILE | 1080p | Yes | Quality profile for Sonarr, found in your Sonarr UI -> Profiles settings | | SONARR_ROOT_FOLDER | /data/ | Yes | Root folder for Sonarr | +| SONARR_BYPASS_IGNORED | true | Yes | Boolean flag to bypass tv shows that are on the Sonarr Exclusion List | | RADARR_API_KEY | 7a392fb4817a46e59f2e84e7d5f021bc | No | API key for Radarr, found in your Radarr UI -> General settings | | RADARR_BASE_URL | http://127.0.0.1:7878 | Yes | Base URL for Radarr, including the 'http' and port | | RADARR_QUALITY_PROFILE | 1080p | Yes | Quality profile for Radarr, found in your Radarr UI -> Profiles settings | | RADARR_ROOT_FOLDER | /data/ | Yes | Root folder for Radarr | +| RADARR_BYPASS_IGNORED | true | Yes | Boolean flag to bypass movies that are on the Radarr Exclusion List | | PLEX_WATCHLIST_URL_1 | https://rss.plex.tv/UUID | No | First Plex Watchlist URL | | PLEX_WATCHLIST_URL_2 | https://rss.plex.tv/UUID | Yes | Second Plex Watchlist URL (if applicable) | diff --git a/build.sbt b/build.sbt index 25a7399..0723509 100644 --- a/build.sbt +++ b/build.sbt @@ -14,8 +14,9 @@ val logbackVersion = "1.4.7" val fs2Version = "3.7.0" val circeVersion = "0.14.5" val circeGenericExtrasVersion = "0.14.3" -val scalatestVersion = "3.2.15" val slf4jVersion = "2.0.9" +val scalatestVersion = "3.2.15" +val scalamockVersion = "5.2.0" libraryDependencies ++= Seq( "org.scala-lang" % "scala-library" % scalaVersion.value % "provided", @@ -29,6 +30,7 @@ libraryDependencies ++= Seq( "io.circe" %% "circe-generic" % circeVersion, "io.circe" %% "circe-parser" % circeVersion % Test, "io.circe" %% "circe-generic-extras" % circeGenericExtrasVersion % Test, + "org.scalamock" %% "scalamock" % scalamockVersion % Test, "org.scalatest" %% "scalatest" % scalatestVersion % Test ) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 849bd49..b7d52ec 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -46,4 +46,12 @@ if [ -n "$REFRESH_INTERVAL_SECONDS" ]; then CMD="$CMD -Dinterval.seconds=$REFRESH_INTERVAL_SECONDS" fi +if [ -n "$SONARR_BYPASS_IGNORED" ]; then + CMD="$CMD -Dsonarr.bypassIgnored=$SONARR_BYPASS_IGNORED" +fi + +if [ -n "$RADARR_BYPASS_IGNORED" ]; then + CMD="$CMD -Dradarr.bypassIgnored=$RADARR_BYPASS_IGNORED" +fi + exec $CMD diff --git a/src/main/scala/ArrUtils.scala b/src/main/scala/ArrUtils.scala deleted file mode 100644 index 4e2393c..0000000 --- a/src/main/scala/ArrUtils.scala +++ /dev/null @@ -1,34 +0,0 @@ -import cats.effect.IO -import io.circe.Json -import org.http4s.{Header, Method, Request, Uri} -import org.http4s.ember.client.EmberClientBuilder -import org.typelevel.ci.CIString - -import org.http4s.circe._ - -object ArrUtils { - - def getToArr(baseUrl: String, apiKey: String, endpoint: String): IO[Either[Throwable, Json]] = { - EmberClientBuilder.default[IO].build.use { client => - val req = Request[IO]( - method = Method.GET, - uri = Uri.unsafeFromString(s"$baseUrl/api/v3/$endpoint") - ).withHeaders(Header.Raw(CIString("X-Api-Key"), apiKey)) - - client.expect[Json](req).attempt - } - } - - def postToArr(baseUrl: String, apiKey: String, endpoint: String)(payload: Json): IO[Either[Throwable, Json]] = { - EmberClientBuilder.default[IO].build.use { client => - val req = Request[IO]( - method = Method.POST, - uri = Uri.unsafeFromString(s"$baseUrl/api/v3/$endpoint") - ).withHeaders(Header.Raw(CIString("X-Api-Key"), apiKey)) - .withEntity(payload) - - client.expect[Json](req).attempt - } - } - -} diff --git a/src/main/scala/Configuration.scala b/src/main/scala/Configuration.scala deleted file mode 100644 index cdadf52..0000000 --- a/src/main/scala/Configuration.scala +++ /dev/null @@ -1,95 +0,0 @@ -import cats.effect.IO -import cats.effect.unsafe.implicits.global -import model.QualityProfile -import io.circe.generic.auto._ -import org.slf4j.LoggerFactory - -import scala.concurrent.duration._ - -class Configuration { - - private val logger = LoggerFactory.getLogger(getClass) - - val refreshInterval: FiniteDuration = getConfigOption("interval.seconds").flatMap(_.toIntOption).getOrElse(60).seconds - - val (sonarrBaseUrl, sonarrApiKey, sonarrQualityProfileId) = getAndTestSonarrUrlAndApiKey.unsafeRunSync() - val sonarrRootFolder: String = getConfigOption("sonarr.rootFolder").getOrElse("/data/") - - val (radarrBaseUrl, radarrApiKey, radarrQualityProfileId) = getAndTestRadarrUrlAndApiKey.unsafeRunSync() - val radarrRootFolder: String = getConfigOption("radarr.rootFolder").getOrElse("/data/") - - val plexWatchlistUrls: List[String] = getPlexWatchlistUrls - - private def getAndTestSonarrUrlAndApiKey: IO[(String, String, Int)] = { - val url = getConfigOption("sonarr.baseUrl").getOrElse("http://localhost:8989") - val apiKey = getConfig("sonarr.apikey") - - ArrUtils.getToArr(url, apiKey, "qualityprofile").map { - case Right(res) => - logger.info("Successfully connected to Sonarr") - val allQualityProfiles = res.as[List[QualityProfile]].getOrElse(List.empty) - val chosenQualityProfile = getConfigOption("sonarr.qualityProfile") - (url, apiKey, getQualityProfileId(allQualityProfiles, chosenQualityProfile)) - case Left(err) => - val message = s"Unable to connect to Sonarr at $url, with error $err" - logger.error(message) - throw new IllegalArgumentException(message) - } - } - - private def getAndTestRadarrUrlAndApiKey: IO[(String, String, Int)] = { - val url = getConfigOption("radarr.baseUrl").getOrElse("http://localhost:7878") - val apiKey = getConfig("radarr.apikey") - - ArrUtils.getToArr(url, apiKey, "qualityprofile").map { - case Right(res) => - logger.info("Successfully connected to Radarr") - val allQualityProfiles = res.as[List[QualityProfile]].getOrElse(List.empty) - val chosenQualityProfile = getConfigOption("radarr.qualityProfile") - (url, apiKey, getQualityProfileId(allQualityProfiles, chosenQualityProfile)) - case Left(err) => - val message = s"Unable to connect to Radarr at $url, with error $err" - logger.error(message) - throw new IllegalArgumentException(message) - } - } - - private def getQualityProfileId(allProfiles: List[QualityProfile], maybeEnvVariable: Option[String]): Int = - (allProfiles, maybeEnvVariable) match { - case (Nil, _) => - logger.error("Could not find any quality profiles defined, check your Sonarr/Radarr settings") - throw new IllegalArgumentException("Unable to fetch quality profiles from Sonarr or Radarr") - 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 { - val message = s"Unable to find quality profile $profileName. Possible values are $allProfiles" - logger.error(message) - throw new IllegalArgumentException(message) - } - } - - private def getPlexWatchlistUrls: List[String] = - Set( - getConfigOption("plex.watchlist1"), - getConfigOption("plex.watchlist2") - ).toList.collect { - case Some(url) => url - } match { - case Nil => - logger.error("Missing plex watchlist URL") - throw new IllegalArgumentException("Missing plex watchlist URL") - case ok => ok - } - - private def getConfig(key: String): String = getConfigOption(key).getOrElse { - logger.error(s"Unable to find configuration for $key, have you set the environment variable?") - throw new IllegalArgumentException(s"Missing argument for $key") - } - - private def getConfigOption(key: String): Option[String] = Option(System.getProperty(key)) -} diff --git a/src/main/scala/Server.scala b/src/main/scala/Server.scala index eeff935..715bb6e 100644 --- a/src/main/scala/Server.scala +++ b/src/main/scala/Server.scala @@ -1,9 +1,13 @@ import cats.effect._ +import configuration.{Configuration, SystemPropertyReader} +import utils.HttpClient object Server extends IOApp { - val config = new Configuration + val configReader = SystemPropertyReader + val httpClient = new HttpClient() + val config = new Configuration(configReader, httpClient) def run(args: List[String]): IO[ExitCode] = { diff --git a/src/main/scala/WatchlistSync.scala b/src/main/scala/WatchlistSync.scala index 2152e27..80cbc50 100644 --- a/src/main/scala/WatchlistSync.scala +++ b/src/main/scala/WatchlistSync.scala @@ -1,65 +1,102 @@ import cats.effect.IO -import org.http4s.{Header, Method, Request, Uri} -import org.http4s.ember.client.EmberClientBuilder -import org.typelevel.ci.CIString +import org.http4s.{Method, Uri} import cats.implicits._ +import configuration.Configuration import io.circe.generic.auto._ -import org.http4s.circe.CirceEntityDecoder._ import io.circe.syntax._ import model._ import org.slf4j.LoggerFactory +import utils.{ArrUtils, HttpClient} object WatchlistSync { private val logger = LoggerFactory.getLogger(getClass) + def run(config: Configuration): IO[Unit] = { + logger.debug("Starting watchlist sync") + for { - watchlistDatas <- config.plexWatchlistUrls.map(fetchWatchlist).sequence + watchlistDatas <- config.plexWatchlistUrls.map(fetchWatchlist(config.client)).sequence watchlistData = watchlistDatas.fold(Watchlist(Set.empty))(mergeWatchLists) - movies <- fetchMovies(config.radarrApiKey, config.radarrBaseUrl) - series <- fetchSeries(config.sonarrApiKey, config.sonarrBaseUrl) + movies <- fetchMovies(config.client)(config.radarrApiKey, config.radarrBaseUrl, config.radarrBypassIgnored) + series <- fetchSeries(config.client)(config.sonarrApiKey, config.sonarrBaseUrl, config.sonarrBypassIgnored) allIds = merge(movies, series) - _ <- missingIds(config)(allIds, watchlistData.items) + _ <- missingIds(config.client)(config)(allIds, watchlistData.items) } yield () } private def mergeWatchLists(l: Watchlist, r: Watchlist): Watchlist = Watchlist(l.items ++ r.items) - private def fetchWatchlist(url: String): IO[Watchlist] = { - EmberClientBuilder.default[IO].build.use { client => - val req = Request[IO]( - method = Method.GET, - uri = Uri.unsafeFromString(url) - ).withHeaders(Header.Raw(CIString("Accept"), "application/json")) - - client.expect[Watchlist](req) + 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(apiKey: String, baseUrl: String): IO[List[RadarrMovie]] = - ArrUtils.getToArr(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") + 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 } - case Left(err) => - logger.warn(s"Received error while trying to fetch movies from Radarr: $err") - throw err - } + } + } yield movies ++ exclusions.map(_.toRadarrMovie) - private def fetchSeries(apiKey: String, baseUrl: String): IO[List[SonarrSeries]] = - ArrUtils.getToArr(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") + 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 } - case Left(err) => - logger.warn(s"Received error while trying to fetch movies from Radarr: $err") - throw err - } + } + } 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) @@ -69,7 +106,7 @@ object WatchlistSync { }.toSet } - private def missingIds(config: Configuration)(allIds: Set[String], watchlist: Set[Item]): IO[Set[Unit]] = + private def missingIds(client: HttpClient)(config: Configuration)(allIds: Set[String], watchlist: Set[Item]): IO[Set[Unit]] = watchlist.map { watchlistedItem => val watchlistIds = watchlistedItem.guids.map(cleanId).toSet @@ -79,10 +116,10 @@ object WatchlistSync { IO.unit case (false, "show") => logger.debug(s"Found show \"${watchlistedItem.title}\" which does not exist yet in Sonarr") - addToSonarr(config)(watchlistedItem) + addToSonarr(client)(config)(watchlistedItem) case (false, "movie") => logger.debug(s"Found movie \"${watchlistedItem.title}\" which does not exist yet in Radarr") - addToRadarr(config)(watchlistedItem) + addToRadarr(client)(config)(watchlistedItem) case (false, c) => logger.warn(s"Found $c \"${watchlistedItem.title}\", but I don't recognize the category") IO.unit @@ -98,11 +135,11 @@ object WatchlistSync { private def findTmdbId(strings: List[String]): Option[Long] = strings.find(_.startsWith("tmdb://")).flatMap(_.stripPrefix("tmdb://").toLongOption) - private def addToRadarr(config: Configuration)(item: Item): IO[Unit] = { + 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(config.radarrBaseUrl, config.radarrApiKey, "movie")(movie.asJson).map { + 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) => @@ -117,11 +154,11 @@ object WatchlistSync { private def findTvdbId(strings: List[String]): Option[Long] = strings.find(_.startsWith("tvdb://")).flatMap(_.stripPrefix("tvdb://").toLongOption) - private def addToSonarr(config: Configuration)(item: Item): IO[Unit] = { + private def addToSonarr(client: HttpClient)(config: Configuration)(item: Item): IO[Unit] = { val show = SonarrPost(item.title, findTvdbId(item.guids).getOrElse(0L), config.sonarrQualityProfileId, config.sonarrRootFolder) - ArrUtils.postToArr(config.sonarrBaseUrl, config.sonarrApiKey, "series")(show.asJson).map { + 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) => diff --git a/src/main/scala/configuration/Configuration.scala b/src/main/scala/configuration/Configuration.scala new file mode 100644 index 0000000..1e03363 --- /dev/null +++ b/src/main/scala/configuration/Configuration.scala @@ -0,0 +1,118 @@ +package configuration + +import cats.effect.IO +import cats.effect.unsafe.implicits.global +import io.circe.generic.auto._ +import model.QualityProfile +import org.http4s.Uri +import org.slf4j.LoggerFactory +import utils.{ArrUtils, HttpClient} + +import scala.concurrent.duration._ + +class Configuration(configReader: ConfigurationReader, val client: HttpClient) { + + private val logger = LoggerFactory.getLogger(getClass) + + val refreshInterval: FiniteDuration = configReader.getConfigOption(Keys.intervalSeconds).flatMap(_.toIntOption).getOrElse(60).seconds + + val (sonarrBaseUrl, sonarrApiKey, sonarrQualityProfileId) = getAndTestSonarrUrlAndApiKey.unsafeRunSync() + val sonarrRootFolder: String = configReader.getConfigOption(Keys.sonarrRootFolder).getOrElse("/data/") + val sonarrBypassIgnored: Boolean = configReader.getConfigOption(Keys.sonarrBypassIgnored).exists(_.toBoolean) + + val (radarrBaseUrl, radarrApiKey, radarrQualityProfileId) = getAndTestRadarrUrlAndApiKey.unsafeRunSync() + val radarrRootFolder: String = configReader.getConfigOption(Keys.radarrRootFolder).getOrElse("/data/") + val radarrBypassIgnored: Boolean = configReader.getConfigOption(Keys.radarrBypassIgnored).exists(_.toBoolean) + + val plexWatchlistUrls: List[Uri] = getPlexWatchlistUrls + + private def getAndTestSonarrUrlAndApiKey: IO[(Uri, String, Int)] = { + 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, "qualityprofile").map { + case Right(res) => + logger.info("Successfully connected to Sonarr") + val allQualityProfiles = res.as[List[QualityProfile]].getOrElse(List.empty) + val chosenQualityProfile = configReader.getConfigOption(Keys.sonarrQualityProfile) + (url, apiKey, getQualityProfileId(allQualityProfiles, chosenQualityProfile)) + case Left(err) => + throwError(s"Unable to connect to Sonarr at $url, with error $err") + } + } + + private def getAndTestRadarrUrlAndApiKey: IO[(Uri, String, Int)] = { + 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, "qualityprofile").map { + case Right(res) => + logger.info("Successfully connected to Radarr") + val allQualityProfiles = res.as[List[QualityProfile]].getOrElse(List.empty) + val chosenQualityProfile = configReader.getConfigOption(Keys.radarrQualityProfile) + (url, apiKey, getQualityProfileId(allQualityProfiles, chosenQualityProfile)) + 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 getPlexWatchlistUrls: 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) + } +} diff --git a/src/main/scala/configuration/ConfigurationReader.scala b/src/main/scala/configuration/ConfigurationReader.scala new file mode 100644 index 0000000..013b87c --- /dev/null +++ b/src/main/scala/configuration/ConfigurationReader.scala @@ -0,0 +1,9 @@ +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/Keys.scala b/src/main/scala/configuration/Keys.scala new file mode 100644 index 0000000..f67c923 --- /dev/null +++ b/src/main/scala/configuration/Keys.scala @@ -0,0 +1,20 @@ +package configuration + +object Keys { + val intervalSeconds = "interval.seconds" + + val sonarrBaseUrl = "sonarr.baseUrl" + val sonarrApiKey = "sonarr.apikey" + val sonarrQualityProfile = "sonarr.qualityProfile" + val sonarrRootFolder = "sonarr.rootFolder" + val sonarrBypassIgnored = "sonarr.bypassIgnored" + + val radarrBaseUrl = "radarr.baseUrl" + val radarrApiKey = "radarr.apikey" + val radarrQualityProfile = "radarr.qualityProfile" + val radarrRootFolder = "radarr.rootFolder" + val radarrBypassIgnored = "radarr.bypassIgnored" + + val plexWatchlist1 = "plex.watchlist1" + val plexWatchlist2 = "plex.watchlist2" +} diff --git a/src/main/scala/model/RadarrMovie.scala b/src/main/scala/model/RadarrMovie.scala index 277f026..ceec55d 100644 --- a/src/main/scala/model/RadarrMovie.scala +++ b/src/main/scala/model/RadarrMovie.scala @@ -1,3 +1,7 @@ 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/utils/ArrUtils.scala b/src/main/scala/utils/ArrUtils.scala new file mode 100644 index 0000000..d43540f --- /dev/null +++ b/src/main/scala/utils/ArrUtils.scala @@ -0,0 +1,19 @@ +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]] = { + val path = Uri.Path.unsafeFromString(s"/api/v3/$endpoint") + client.httpRequest(Method.GET, baseUrl.withPath(path), Some(apiKey)) + } + + def postToArr(client: HttpClient)(baseUrl: Uri, apiKey: String, endpoint: String)(payload: Json): IO[Either[Throwable, Json]] = { + val path = Uri.Path.unsafeFromString(s"/api/v3/$endpoint") + client.httpRequest(Method.POST, baseUrl.withPath(path), Some(apiKey), Some(payload)) + } + +} diff --git a/src/main/scala/utils/HttpClient.scala b/src/main/scala/utils/HttpClient.scala new file mode 100644 index 0000000..e8ecb3d --- /dev/null +++ b/src/main/scala/utils/HttpClient.scala @@ -0,0 +1,20 @@ +package utils + +import cats.effect.IO +import io.circe.Json +import org.http4s.{Header, Method, Request, Uri} +import org.http4s.ember.client.EmberClientBuilder +import org.typelevel.ci.CIString + +import org.http4s.circe._ + +class HttpClient { + def httpRequest(method: Method, url: Uri, apiKey: Option[String] = None, payload: Option[Json] = None): IO[Either[Throwable, Json]] = { + val baseRequest = Request[IO](method = method, uri = url).withHeaders(Header.Raw(CIString("Accept"), "application/json")) + val requestWithApiKey = apiKey.fold(baseRequest)(key => baseRequest.withHeaders(Header.Raw(CIString("X-Api-Key"), key))) + val requestWithPayload = payload.fold(requestWithApiKey)(p => requestWithApiKey.withEntity(p)) + + EmberClientBuilder.default[IO].build.use(_.expect[Json](requestWithPayload).attempt) + } +} + diff --git a/src/test/scala/configuration/ConfigurationSpec.scala b/src/test/scala/configuration/ConfigurationSpec.scala new file mode 100644 index 0000000..45a3547 --- /dev/null +++ b/src/test/scala/configuration/ConfigurationSpec.scala @@ -0,0 +1,116 @@ +package configuration + +import cats.effect.IO +import model.QualityProfile +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.syntax.EncoderOps + +class ConfigurationSpec extends AnyFlatSpec with Matchers with MockFactory { + "A configuration.Configuration" should "start with all required values provided" in { + + val mockConfigReader = createMockConfigReader() + val mockHttpClient = createMockHttpClient() + + val config = new Configuration(mockConfigReader, mockHttpClient) + noException should be thrownBy config + config.radarrApiKey shouldBe "radarr-api-key" + config.sonarrApiKey shouldBe "sonarr-api-key" + config.plexWatchlistUrls shouldBe inAnyOrder(List(Uri.unsafeFromString("https://rss.plex.tv/1"))) + } + + it should "fail if missing sonarr API key" in { + + val mockConfigReader = createMockConfigReader(sonarrApiKey = None) + val mockHttpClient = createMockHttpClient() + + an[IllegalArgumentException] should be thrownBy new Configuration(mockConfigReader, mockHttpClient) + } + + it should "fail if missing radarr API key" in { + + val mockConfigReader = createMockConfigReader(radarrApiKey = None) + val mockHttpClient = createMockHttpClient() + + an[IllegalArgumentException] should be thrownBy new Configuration(mockConfigReader, mockHttpClient) + } + + it should "fail if missing plex watchlist 1 and 2" in { + + val mockConfigReader = createMockConfigReader(plexWatchlist1 = None) + val mockHttpClient = createMockHttpClient() + + an[IllegalArgumentException] should be thrownBy new Configuration(mockConfigReader, mockHttpClient) + } + + it should "pass if missing plex watchlist 1 but there's a plex watchlist 2" in { + + val mockConfigReader = createMockConfigReader(plexWatchlist1 = None, plexWatchlist2 = Some(s"https://rss.plex.tv/2")) + val mockHttpClient = createMockHttpClient() + + val config = new Configuration(mockConfigReader, mockHttpClient) + noException should be thrownBy config + config.plexWatchlistUrls shouldBe inAnyOrder(List(Uri.unsafeFromString("https://rss.plex.tv/2"))) + } + + it should "pass if both plex watchlist 1 and 2 are provided" in { + + val mockConfigReader = createMockConfigReader(plexWatchlist1 = Some(s"https://rss.plex.tv/1"), plexWatchlist2 = Some(s"https://rss.plex.tv/2")) + val mockHttpClient = createMockHttpClient() + + val config = new Configuration(mockConfigReader, mockHttpClient) + noException should be thrownBy config + config.plexWatchlistUrls shouldBe inAnyOrder(List( + Uri.unsafeFromString("https://rss.plex.tv/1"), + Uri.unsafeFromString("https://rss.plex.tv/2") + )) + } + + private def createMockConfigReader( + sonarrApiKey: Option[String] = Some("sonarr-api-key"), + radarrApiKey: Option[String] = Some("radarr-api-key"), + plexWatchlist1: Option[String] = Some(s"https://rss.plex.tv/1"), + plexWatchlist2: Option[String] = None + ): ConfigurationReader = { + val unset = None + + val mockConfigReader = mock[ConfigurationReader] + (mockConfigReader.getConfigOption _).expects(Keys.intervalSeconds).returning(unset).anyNumberOfTimes() + (mockConfigReader.getConfigOption _).expects(Keys.sonarrBaseUrl).returning(unset).anyNumberOfTimes() + (mockConfigReader.getConfigOption _).expects(Keys.sonarrApiKey).returning(sonarrApiKey).anyNumberOfTimes() + (mockConfigReader.getConfigOption _).expects(Keys.sonarrQualityProfile).returning(unset).anyNumberOfTimes() + (mockConfigReader.getConfigOption _).expects(Keys.sonarrRootFolder).returning(unset).anyNumberOfTimes() + (mockConfigReader.getConfigOption _).expects(Keys.sonarrBypassIgnored).returning(unset).anyNumberOfTimes() + (mockConfigReader.getConfigOption _).expects(Keys.radarrBaseUrl).returning(unset).anyNumberOfTimes() + (mockConfigReader.getConfigOption _).expects(Keys.radarrApiKey).returning(radarrApiKey).anyNumberOfTimes() + (mockConfigReader.getConfigOption _).expects(Keys.radarrQualityProfile).returning(unset).anyNumberOfTimes() + (mockConfigReader.getConfigOption _).expects(Keys.radarrRootFolder).returning(unset).anyNumberOfTimes() + (mockConfigReader.getConfigOption _).expects(Keys.radarrBypassIgnored).returning(unset).anyNumberOfTimes() + (mockConfigReader.getConfigOption _).expects(Keys.plexWatchlist1).returning(plexWatchlist1).anyNumberOfTimes() + (mockConfigReader.getConfigOption _).expects(Keys.plexWatchlist2).returning(plexWatchlist2).anyNumberOfTimes() + mockConfigReader + } + + private def createMockHttpClient(): HttpClient = { + val mockHttpClient = mock[HttpClient] + + val defaultQualityProfileResponse = List(QualityProfile("1080p", 5)) + (mockHttpClient.httpRequest _).expects( + Method.GET, + Uri.unsafeFromString("http://localhost:8989").withPath(Uri.Path.unsafeFromString("/api/v3/qualityprofile")), + Some("sonarr-api-key"), + None + ).returning(IO.pure(Right(defaultQualityProfileResponse.asJson))).anyNumberOfTimes() + (mockHttpClient.httpRequest _).expects( + Method.GET, + Uri.unsafeFromString("http://localhost:7878").withPath(Uri.Path.unsafeFromString("/api/v3/qualityprofile")), + Some("radarr-api-key"), + None + ).returning(IO.pure(Right(defaultQualityProfileResponse.asJson))).anyNumberOfTimes() + mockHttpClient + } +}