diff --git a/.gitignore b/.gitignore index 8198b1e..3c76b18 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ target/ ### Environment file for local variables .env +config/ *.txt diff --git a/build.sbt b/build.sbt index 23e77bf..071cdac 100644 --- a/build.sbt +++ b/build.sbt @@ -19,6 +19,7 @@ val scalamockVersion = "5.2.0" val scalatestVersion = "3.2.17" val shapelessVersion = "2.3.10" val slf4jVersion = "2.0.9" +val snakeYamlVersion = "2.0" val vaultVersion = "3.5.0" libraryDependencies ++= Seq( @@ -40,6 +41,7 @@ libraryDependencies ++= Seq( "org.typelevel" %% "vault" % vaultVersion, "io.circe" %% "circe-generic" % circeVersion, "io.circe" %% "circe-generic-extras" % circeGenericExtrasVersion, + "org.yaml" % "snakeyaml" % snakeYamlVersion, "io.circe" %% "circe-parser" % circeVersion % Test, "org.scalamock" %% "scalamock" % scalamockVersion % Test, "org.scalatest" %% "scalatest" % scalatestVersion % Test diff --git a/docker/Dockerfile b/docker/Dockerfile index 86755ce..757ea40 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -2,11 +2,9 @@ FROM hseeberger/scala-sbt:eclipse-temurin-11.0.14.1_1.6.2_2.13.8 as build WORKDIR /app -COPY / / -RUN sbt update +COPY . . -COPY .. . -RUN sbt compile stage +RUN sbt update compile stage FROM openjdk:11-jre-slim diff --git a/src/main/resources/config-template.yaml b/src/main/resources/config-template.yaml new file mode 100644 index 0000000..9fc161a --- /dev/null +++ b/src/main/resources/config-template.yaml @@ -0,0 +1,40 @@ +## Watchlistarr Configuration +## If you want to specify your own configuration, copy this file, and rename it to 'config.yaml' + +## How often do you want Watchlistarr to pull the latest from Plex? +## In general, 10 seconds is okay. +## If you're running this on a slower system (e.g. Raspberry Pi), you may want to increase this to 60 seconds. +interval: + seconds: 60 + +## Sonarr Configuration +sonarr: + #baseUrl: "127.0.0.1:8989" + #apikey: "YOUR-API-KEY" + #qualityProfile: "Your Desired Sonarr Quality Profile" + #rootFolder: "/root/folder/location" + bypassIgnored: false + seasonMonitoring: all # Possible values under 'MonitorTypes' in sonarr.tv/docs/api + tags: + - watchlistarr + +## Radarr Configuration +radarr: + #baseUrl: "127.0.0.1:7878" + #apikey: "YOUR-API-KEY" + #qualityProfile: "Your Desired Radarr Quality Profile" + #rootFolder: "/root/folder/location" + bypassIgnored: false + tags: + - watchlistarr + +## Plex Configuration +plex: + #token: "YOUR-PLEX-TOKEN" + skipfriendsync: false # Don't sync friends watchlists, only your own + +delete: + movie: false + endedShow: false + continuingShow: false + interval.days: 7 diff --git a/src/main/scala/Server.scala b/src/main/scala/Server.scala index be472b7..c517628 100644 --- a/src/main/scala/Server.scala +++ b/src/main/scala/Server.scala @@ -1,7 +1,7 @@ import cats.effect._ import cats.implicits.catsSyntaxTuple3Parallel -import configuration.{Configuration, ConfigurationUtils, SystemPropertyReader} +import configuration.{Configuration, ConfigurationUtils, FileAndSystemPropertyReader, SystemPropertyReader} import http.HttpClient import org.slf4j.LoggerFactory @@ -18,40 +18,44 @@ object Server extends IOApp { } def run(args: List[String]): IO[ExitCode] = { - val configReader = SystemPropertyReader + val configReader = FileAndSystemPropertyReader val httpClient = new HttpClient for { - memoizedConfigIo <- ConfigurationUtils.create(configReader, httpClient).memoize + initialConfig <- ConfigurationUtils.create(configReader, httpClient) + configRef <- Ref.of[IO, Configuration](initialConfig) result <- ( - pingTokenSync(memoizedConfigIo, httpClient), - plexTokenSync(memoizedConfigIo, httpClient), - plexTokenDeleteSync(memoizedConfigIo, httpClient) + pingTokenSync(configRef, httpClient), + plexTokenSync(configRef, httpClient), + plexTokenDeleteSync(configRef, httpClient) ).parTupled.as(ExitCode.Success) } yield result } - private def pingTokenSync(configIO: IO[Configuration], httpClient: HttpClient): IO[Unit] = + private def fetchLatestConfig(configRef: Ref[IO, Configuration]): IO[Configuration] = + configRef.get + + private def pingTokenSync(configRef: Ref[IO, Configuration], httpClient: HttpClient): IO[Unit] = for { - config <- configIO + config <- fetchLatestConfig(configRef) _ <- PingTokenSync.run(config, httpClient) _ <- IO.sleep(24.hours) - _ <- pingTokenSync(configIO, httpClient) + _ <- pingTokenSync(configRef, httpClient) } yield () - private def plexTokenSync(configIO: IO[Configuration], httpClient: HttpClient, firstRun: Boolean = true): IO[Unit] = + private def plexTokenSync(configRef: Ref[IO, Configuration], httpClient: HttpClient, firstRun: Boolean = true): IO[Unit] = for { - config <- configIO + config <- fetchLatestConfig(configRef) _ <- PlexTokenSync.run(config, httpClient, firstRun) _ <- IO.sleep(config.refreshInterval) - _ <- plexTokenSync(configIO, httpClient, firstRun = false) + _ <- plexTokenSync(configRef, httpClient, firstRun = false) } yield () - private def plexTokenDeleteSync(configIO: IO[Configuration], httpClient: HttpClient): IO[Unit] = + private def plexTokenDeleteSync(configRef: Ref[IO, Configuration], httpClient: HttpClient): IO[Unit] = for { - config <- configIO + config <- fetchLatestConfig(configRef) _ <- PlexTokenDeleteSync.run(config, httpClient) _ <- IO.sleep(config.deleteConfiguration.deleteInterval) - _ <- plexTokenDeleteSync(configIO, httpClient) + _ <- plexTokenDeleteSync(configRef, httpClient) } yield () } diff --git a/src/main/scala/configuration/FileAndSystemPropertyReader.scala b/src/main/scala/configuration/FileAndSystemPropertyReader.scala new file mode 100644 index 0000000..10b6210 --- /dev/null +++ b/src/main/scala/configuration/FileAndSystemPropertyReader.scala @@ -0,0 +1,83 @@ +package configuration + +import org.slf4j.LoggerFactory +import org.yaml.snakeyaml.Yaml + +import java.io.{File, FileInputStream} +import java.nio.file.{Files, Paths, StandardCopyOption} +import java.util +import scala.jdk.CollectionConverters.{ListHasAsScala, MapHasAsScala} + +object FileAndSystemPropertyReader extends ConfigurationReader { + + private val logger = LoggerFactory.getLogger(getClass) + + private lazy val data: Map[String, String] = { + val yaml = new Yaml() + val configDirPath = "config" + val configFile = new File(s"$configDirPath/config.yaml") + + try { + // Ensure parent config folder exists + val parentDir = configFile.getParentFile + if (!parentDir.exists()) parentDir.mkdirs() + + if (!configFile.exists()) { + val resourceStream = getClass.getClassLoader.getResourceAsStream("config-template.yaml") + if (resourceStream != null) { + try { + Files.copy(resourceStream, Paths.get(configFile.toURI), StandardCopyOption.REPLACE_EXISTING) + logger.info(s"Created config file in ${configFile.getPath}") + } finally { + resourceStream.close() + } + } else { + logger.debug("config-template.yaml resource not found") + } + } + + if (configFile.exists()) { + val inputStream = new FileInputStream(configFile) + val result = yaml.load[util.Map[String, Object]](inputStream).asScala + inputStream.close() + flattenYaml(Map.from(result)) + } else { + Map.empty[String, String] + } + } catch { + case e: Exception => + logger.debug(s"Failed to read from config.yaml: ${e.getMessage}") + Map.empty[String, String] + } + } + + override def getConfigOption(key: String): Option[String] = + if (data.contains(key)) + data.get(key) + else + SystemPropertyReader.getConfigOption(key) + + private def flattenYaml(yaml: Map[String, _]): Map[String, String] = yaml.flatMap { + case (k, v: util.ArrayList[_]) => + List((k, v.asScala.mkString(","))) + + case (k, v: String) => + List((k, v)) + + case (k, v: Integer) => + List((k, v.toString)) + + case (k, v: java.lang.Boolean) => + List((k, v.toString)) + + case (k, v: util.LinkedHashMap[String, _]) => + val flattenedInner = flattenYaml(Map.from(v.asScala)) + flattenedInner.map { case (innerK, innerV) => + (s"$k.$innerK", innerV) + }.toList + + case (k, v) => + logger.warn(s"Unhandled config pair of type: ${k.getClass} -> ${v.getClass}") + List((k, v.toString)) + } +} diff --git a/src/main/scala/configuration/SystemPropertyReader.scala b/src/main/scala/configuration/SystemPropertyReader.scala index 2c48896..e01d6cb 100644 --- a/src/main/scala/configuration/SystemPropertyReader.scala +++ b/src/main/scala/configuration/SystemPropertyReader.scala @@ -1,5 +1,5 @@ package configuration object SystemPropertyReader extends ConfigurationReader { - def getConfigOption(key: String): Option[String] = Option(System.getProperty(key)) + override def getConfigOption(key: String): Option[String] = Option(System.getProperty(key)) }