Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add auto detection of root folders #16

Merged
merged 1 commit into from
Oct 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
}
}