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/README.md b/README.md index 5b23594..ee27ecd 100644 --- a/README.md +++ b/README.md @@ -52,13 +52,10 @@ docker run \ -e SONARR_API_KEY=YOUR_API_KEY \ -e RADARR_API_KEY=YOUR_API_KEY \ -e PLEX_TOKEN=YOUR_PLEX_TOKEN \ - -e REFRESH_INTERVAL_SECONDS=5 \ + -v config:/app/config \ nylonee/watchlistarr ``` -For a full list of possible environment variables to configure the app with, see the Environment Variables section of -this Readme - Docker tag options: * `latest` - Stable version, follows the Releases @@ -93,37 +90,21 @@ Save this file in the same directory as the .jar file, then create a shortcut to Windows startup folder. In the properties of the shortcut, set it to start minimized (Thanks Redditor u/DanCBooper for tip) -#### Java variables - -For a full list of options to pass in when running the application on native java, -refer to the environment variables chart below, and cross-reference the key to the internal key -in [entrypoint.sh](https://github.com/nylonee/watchlistarr/blob/main/docker/entrypoint.sh) - -### Environment Variables - -| Key | Default | Description | -|--------------------------------|-----------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| SONARR_API_KEY* | | API key for Sonarr, found in your Sonarr UI -> General settings | -| RADARR_API_KEY* | | API key for Radarr, found in your Radarr UI -> General settings | -| PLEX_TOKEN* | | Token for Plex, retrieved via [this tutorial](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/). Note that multiple tokens can be provided, comma separated | -| REFRESH_INTERVAL_SECONDS | 60 | Number of seconds to wait in between checking the watchlist | -| SONARR_BASE_URL | http://localhost:8989 | Base URL for Sonarr, including the 'http' and port and any configured urlbase | -| SONARR_QUALITY_PROFILE | | Quality profile for Sonarr, found in your Sonarr UI -> Profiles settings. If not set, will grab the first one it finds on Sonarr | -| SONARR_ROOT_FOLDER | | Root folder for Sonarr. If not set, will grab the first one it finds on Sonarr | -| SONARR_BYPASS_IGNORED | false | Boolean flag to bypass tv shows that are on the Sonarr Exclusion List | -| SONARR_SEASON_MONITORING | all | Default monitoring for new seasons added to Sonarr. Full list of options are found in the [Sonarr API Docs](https://sonarr.tv/docs/api/#/Series/post_api_v3_series) under **MonitorTypes** | -| SONARR_TAGS | | Tags to assign to tv shows that are added via Watchlistarr, comma separated | -| RADARR_BASE_URL | http://127.0.0.1:7878 | Base URL for Radarr, including the 'http' and port and any configured urlbase | -| RADARR_QUALITY_PROFILE | | Quality profile for Radarr, found in your Radarr UI -> Profiles settings. If not set, will grab the first one it finds on Radarr | -| RADARR_ROOT_FOLDER | | Root folder for Radarr. If not set, will grab the first one it finds on Radarr | -| RADARR_BYPASS_IGNORED | false | Boolean flag to bypass movies that are on the Radarr Exclusion List | -| RADARR_TAGS | | Tags to assign to movies that are added via Watchlistarr, comma separated | -| SKIP_FRIEND_SYNC | false | Boolean flag to toggle between only syncing your own content, vs syncing your own and all your friends content | -| ALLOW_MOVIE_DELETING | false | Boolean flag to enable/disable the full Watchlistarr sync for movies. If enabled, movies that are not watchlisted will be deleted from Radarr | -| ALLOW_ENDED_SHOW_DELETING | false | Boolean flag to enable/disable the full Watchlistarr sync for ended shows. If enabled, shows that have no more planned seasons and are not watchlisted will be deleted from Sonarr | -| ALLOW_CONTINUING_SHOW_DELETING | false | Boolean flag to enable/disable the full Watchlistarr sync for continuing shows. If enabled, shows that still have planned seasons and are not watchlisted will be deleted from Sonarr | -| DELETE_INTERVAL_DAYS | 7 | Number of days to wait before deleting content from the arrs (Deleting must be enabled) | -| LOG_LEVEL | INFO | Level of logging, set to DEBUG for more verbose logs, or WARN for less logs | +### Configuration + +Running Watchlistarr successfully for the first time will generate a `config.yaml` file with additional configuration. +Modify this file to your heart's desire, then restart Watchlistarr + +#### Enabling debug mode +Sometimes, you'll need more information from the app. To enable debug mode in Docker, add the following line to your command: +``` +-e LOG_LEVEL=DEBUG +``` + +To enable debug mode in Java, add the following line: +``` +"-Dlog.level=DEBUG" +``` ## Plex Pass Alternative 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..486b788 --- /dev/null +++ b/src/main/resources/config-template.yaml @@ -0,0 +1,59 @@ +## Watchlistarr Configuration +## Uncomment the lines you would like to configure, then save this file and restart Watchlistarr + +################################################################# +## 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: 10 + + +################################################################# +## Sonarr Configuration +################################################################# + +#sonarr: +# baseUrl: "127.0.0.1:8989" # Base URL for Sonarr, including the 'http' and port and any configured urlbase +# apikey: "YOUR-API-KEY" # API key for Sonarr, found in your Sonarr UI -> General settings +# qualityProfile: "Your Desired Sonarr Quality Profile" # If not set, will grab the first one it finds on Sonarr +# rootFolder: "/root/folder/location" # Root folder for Sonarr. If not set, will grab the first one it finds on Sonarr +# bypassIgnored: false +# seasonMonitoring: all # Possible values under 'MonitorTypes' in sonarr.tv/docs/api +# tags: +# - watchlistarr + + +################################################################# +## Radarr Configuration +################################################################# + +#radarr: +# baseUrl: "127.0.0.1:7878" # Base URL for Radarr, including the 'http' and port and any configured urlbase +# apikey: "YOUR-API-KEY" +# qualityProfile: "Your Desired Radarr Quality Profile" # If not set, will grab the first one it finds on Radarr +# rootFolder: "/root/folder/location" # If not set, will grab the first one it finds on Radarr +# bypassIgnored: false # Boolean flag to bypass tv shows that are on the Sonarr Exclusion List +# tags: +# - watchlistarr + + +################################################################# +## Plex Configuration +################################################################# + +#plex: +# token: "YOUR-PLEX-TOKEN" # Multiple tokens can be provided +# skipfriendsync: false # Don't sync friends watchlists, only your own + + +################################################################# +## Delete Sync Configuration +################################################################# + +#delete: +# movie: false # If enabled, movies that are not watchlisted will be deleted from Radarr +# endedShow: false # If enabled, shows that have no more planned seasons and are not watchlisted will be deleted from Sonarr +# continuingShow: false # If enabled, shows that still have planned seasons and are not watchlisted will be deleted from Sonarr +# interval.days: 7 # Number of days to wait before deleting content from the arrs (Deleting must be enabled) 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)) }