From c0b3a1d6372d10401b218d6dfd346b5f4ce6eb4a Mon Sep 17 00:00:00 2001 From: Nihal Mirpuri Date: Sun, 29 Oct 2023 14:10:17 +0000 Subject: [PATCH] add auto detection of root folders (#16) --- .../scala/configuration/Configuration.scala | 58 ++++++++--- src/main/scala/model/RootFolder.scala | 3 + src/test/resources/exclusions.json | 14 +++ src/test/resources/importlistexclusion.json | 7 ++ src/test/resources/rootFolder.json | 30 ++++++ src/test/scala/WatchlistSyncSpec.scala | 28 ++++++ .../configuration/ConfigurationSpec.scala | 98 ++++++++++++++++++- 7 files changed, 222 insertions(+), 16 deletions(-) create mode 100644 src/main/scala/model/RootFolder.scala create mode 100644 src/test/resources/exclusions.json create mode 100644 src/test/resources/importlistexclusion.json create mode 100644 src/test/resources/rootFolder.json diff --git a/src/main/scala/configuration/Configuration.scala b/src/main/scala/configuration/Configuration.scala index 075bf93..2946482 100644 --- a/src/main/scala/configuration/Configuration.scala +++ b/src/main/scala/configuration/Configuration.scala @@ -3,7 +3,7 @@ package configuration import cats.effect.IO import cats.effect.unsafe.IORuntime import io.circe.generic.auto._ -import model.QualityProfile +import model.{QualityProfile, RootFolder} import org.http4s.Uri import org.slf4j.LoggerFactory import utils.{ArrUtils, HttpClient} @@ -16,17 +16,15 @@ class Configuration(configReader: ConfigurationReader, val client: HttpClient)(i 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 (sonarrBaseUrl, sonarrApiKey, sonarrQualityProfileId, sonarrRootFolder) = getSonarrConfig.unsafeRunSync() 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 (radarrBaseUrl, radarrApiKey, radarrQualityProfileId, radarrRootFolder) = getRadarrConfig.unsafeRunSync() val radarrBypassIgnored: Boolean = configReader.getConfigOption(Keys.radarrBypassIgnored).exists(_.toBoolean) val plexWatchlistUrls: List[Uri] = getPlexWatchlistUrls - private def getAndTestSonarrUrlAndApiKey: IO[(Uri, String, Int)] = { + private def getSonarrConfig: 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") @@ -34,18 +32,26 @@ class Configuration(configReader: ConfigurationReader, val client: HttpClient)(i } val apiKey = configReader.getConfigOption(Keys.sonarrApiKey).getOrElse(throwError("Unable to find sonarr API key")) - ArrUtils.getToArr(client)(url, apiKey, "qualityprofile").map { + ArrUtils.getToArr(client)(url, apiKey, "rootFolder").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)) + 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 getAndTestRadarrUrlAndApiKey: IO[(Uri, String, Int)] = { + private def getRadarrConfig: 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") @@ -53,17 +59,41 @@ class Configuration(configReader: ConfigurationReader, val client: HttpClient)(i } val apiKey = configReader.getConfigOption(Keys.radarrApiKey).getOrElse(throwError("Unable to find radarr API key")) - ArrUtils.getToArr(client)(url, apiKey, "qualityprofile").map { + 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)) + (url, apiKey, getQualityProfileId(allQualityProfiles, chosenQualityProfile), rootFolder) case Left(err) => throwError(s"Unable to connect to Radarr at $url, with error $err") } + ) } + 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 getQualityProfileId(allProfiles: List[QualityProfile], maybeEnvVariable: Option[String]): Int = (allProfiles, maybeEnvVariable) match { case (Nil, _) => diff --git a/src/main/scala/model/RootFolder.scala b/src/main/scala/model/RootFolder.scala new file mode 100644 index 0000000..8c0f0db --- /dev/null +++ b/src/main/scala/model/RootFolder.scala @@ -0,0 +1,3 @@ +package model + +case class RootFolder(path: String, accessible: Boolean) diff --git a/src/test/resources/exclusions.json b/src/test/resources/exclusions.json new file mode 100644 index 0000000..6f39b0a --- /dev/null +++ b/src/test/resources/exclusions.json @@ -0,0 +1,14 @@ +[ + { + "tmdbId": 226979, + "movieTitle": "Test", + "movieYear": 2013, + "id": 1 + }, + { + "tmdbId": 762, + "movieTitle": "Monty Python and the Holy Grail", + "movieYear": 1975, + "id": 2 + } +] diff --git a/src/test/resources/importlistexclusion.json b/src/test/resources/importlistexclusion.json new file mode 100644 index 0000000..722fe14 --- /dev/null +++ b/src/test/resources/importlistexclusion.json @@ -0,0 +1,7 @@ +[ + { + "tvdbId": 372848, + "title": "The Test", + "id": 1 + } +] diff --git a/src/test/resources/rootFolder.json b/src/test/resources/rootFolder.json new file mode 100644 index 0000000..4322962 --- /dev/null +++ b/src/test/resources/rootFolder.json @@ -0,0 +1,30 @@ +[ + { + "path": "/data1", + "accessible": false, + "freeSpace": 6942155677696, + "unmappedFolders": [], + "id": 1 + }, + { + "path": "/data2", + "accessible": true, + "freeSpace": 6942155677696, + "unmappedFolders": [], + "id": 2 + }, + { + "path": "/data3", + "accessible": true, + "freeSpace": 6942155677696, + "unmappedFolders": [], + "id": 3 + }, + { + "path": "/data4", + "accessible": false, + "freeSpace": 6942155677696, + "unmappedFolders": [], + "id": 4 + } +] diff --git a/src/test/scala/WatchlistSyncSpec.scala b/src/test/scala/WatchlistSyncSpec.scala index 6369e1d..803a2c7 100644 --- a/src/test/scala/WatchlistSyncSpec.scala +++ b/src/test/scala/WatchlistSyncSpec.scala @@ -49,4 +49,32 @@ class WatchlistSyncSpec extends AnyFlatSpec with Matchers { 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/ConfigurationSpec.scala index ed549fa..cf302d1 100644 --- a/src/test/scala/configuration/ConfigurationSpec.scala +++ b/src/test/scala/configuration/ConfigurationSpec.scala @@ -9,8 +9,11 @@ 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 { @@ -71,8 +74,87 @@ class ConfigurationSpec extends AnyFlatSpec with Matchers with MockFactory { )) } + it should "fetch the first accessible root folder of sonarr if none is provided" in { + + val mockConfigReader = createMockConfigReader() + val mockHttpClient = createMockHttpClient() + + val config = new Configuration(mockConfigReader, mockHttpClient) + noException should be thrownBy config + config.sonarrRootFolder shouldBe "/data2" + } + + it should "find the root folder provided in sonarr config" in { + + val mockConfigReader = createMockConfigReader(sonarrRootFolder = Some("/data3")) + val mockHttpClient = createMockHttpClient() + + val config = new Configuration(mockConfigReader, mockHttpClient) + noException should be thrownBy config + config.sonarrRootFolder shouldBe "/data3" + } + + it should "find the root folder with a trailing slash provided in sonarr config" in { + + val mockConfigReader = createMockConfigReader(sonarrRootFolder = Some("/data3/")) + val mockHttpClient = createMockHttpClient() + + val config = new Configuration(mockConfigReader, mockHttpClient) + noException should be thrownBy config + config.sonarrRootFolder shouldBe "/data3" + } + + it should "throw an error if the sonarr root folder provided can't be found" in { + + val mockConfigReader = createMockConfigReader(sonarrRootFolder = Some("/unknown")) + val mockHttpClient = createMockHttpClient() + + an[IllegalArgumentException] should be thrownBy new Configuration(mockConfigReader, mockHttpClient) + } + + it should "fetch the first accessible root folder of radarr if none is provided" in { + + val mockConfigReader = createMockConfigReader() + val mockHttpClient = createMockHttpClient() + + val config = new Configuration(mockConfigReader, mockHttpClient) + noException should be thrownBy config + config.radarrRootFolder shouldBe "/data2" + } + + + it should "find the root folder provided in radarr config" in { + + val mockConfigReader = createMockConfigReader(radarrRootFolder = Some("/data3")) + val mockHttpClient = createMockHttpClient() + + val config = new Configuration(mockConfigReader, mockHttpClient) + noException should be thrownBy config + config.radarrRootFolder shouldBe "/data3" + } + + it should "find the root folder with a trailing slash provided in radarr config" in { + + val mockConfigReader = createMockConfigReader(radarrRootFolder = Some("/data3/")) + val mockHttpClient = createMockHttpClient() + + val config = new Configuration(mockConfigReader, mockHttpClient) + noException should be thrownBy config + config.radarrRootFolder shouldBe "/data3" + } + + it should "throw an error if the radarr root folder provided can't be found" in { + + val mockConfigReader = createMockConfigReader(radarrRootFolder = Some("/unknown")) + val mockHttpClient = createMockHttpClient() + + an[IllegalArgumentException] should be thrownBy new Configuration(mockConfigReader, mockHttpClient) + } + private def createMockConfigReader( sonarrApiKey: Option[String] = Some("sonarr-api-key"), + sonarrRootFolder: Option[String] = None, + radarrRootFolder: Option[String] = None, radarrApiKey: Option[String] = Some("radarr-api-key"), plexWatchlist1: Option[String] = Some(s"https://rss.plex.tv/1"), plexWatchlist2: Option[String] = None @@ -84,12 +166,12 @@ class ConfigurationSpec extends AnyFlatSpec with Matchers with MockFactory { (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.sonarrRootFolder).returning(sonarrRootFolder).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.radarrRootFolder).returning(radarrRootFolder).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() @@ -112,6 +194,18 @@ class ConfigurationSpec extends AnyFlatSpec with Matchers with MockFactory { Some("radarr-api-key"), None ).returning(IO.pure(Right(defaultQualityProfileResponse.asJson))).anyNumberOfTimes() + (mockHttpClient.httpRequest _).expects( + Method.GET, + Uri.unsafeFromString("http://localhost:8989").withPath(Uri.Path.unsafeFromString("/api/v3/rootFolder")), + Some("sonarr-api-key"), + None + ).returning(IO.pure(parse(Source.fromResource("rootFolder.json").getLines().mkString("\n")))).anyNumberOfTimes() + (mockHttpClient.httpRequest _).expects( + Method.GET, + Uri.unsafeFromString("http://localhost:7878").withPath(Uri.Path.unsafeFromString("/api/v3/rootFolder")), + Some("radarr-api-key"), + None + ).returning(IO.pure(parse(Source.fromResource("rootFolder.json").getLines().mkString("\n")))).anyNumberOfTimes() mockHttpClient } }