Skip to content

Commit

Permalink
add auto detection of root folders (#16)
Browse files Browse the repository at this point in the history
  • Loading branch information
nylonee authored Oct 29, 2023
1 parent 6860d21 commit c0b3a1d
Show file tree
Hide file tree
Showing 7 changed files with 222 additions and 16 deletions.
58 changes: 44 additions & 14 deletions src/main/scala/configuration/Configuration.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -16,54 +16,84 @@ 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")
Uri.unsafeFromString(default)
}
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")
Uri.unsafeFromString(default)
}
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, _) =>
Expand Down
3 changes: 3 additions & 0 deletions src/main/scala/model/RootFolder.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package model

case class RootFolder(path: String, accessible: Boolean)
14 changes: 14 additions & 0 deletions src/test/resources/exclusions.json
Original file line number Diff line number Diff line change
@@ -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
}
]
7 changes: 7 additions & 0 deletions src/test/resources/importlistexclusion.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[
{
"tvdbId": 372848,
"title": "The Test",
"id": 1
}
]
30 changes: 30 additions & 0 deletions src/test/resources/rootFolder.json
Original file line number Diff line number Diff line change
@@ -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
}
]
28 changes: 28 additions & 0 deletions src/test/scala/WatchlistSyncSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
}
98 changes: 96 additions & 2 deletions src/test/scala/configuration/ConfigurationSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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
}
}

0 comments on commit c0b3a1d

Please sign in to comment.