From 3c548f281b3e0111ed5cd441f410491e172b2c34 Mon Sep 17 00:00:00 2001 From: fezster <97789007+fezster@users.noreply.github.com> Date: Sun, 25 Jan 2026 13:43:12 +0000 Subject: [PATCH 1/5] Fix 403 forbidden error when following redirect Remove Host header which is rejected when redirect followed. Instead, let http4s set the Host header --- docker/Dockerfile | 9 +++++++-- src/main/scala/http/HttpClient.scala | 3 +-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 74f0bc2..2b2bbb4 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,13 +1,18 @@ -FROM hseeberger/scala-sbt:eclipse-temurin-11.0.14.1_1.6.2_2.13.8 as build +FROM hseeberger/scala-sbt:eclipse-temurin-11.0.14.1_1.6.2_2.13.8 AS build WORKDIR /app +# Copy project definition first for caching +COPY project ./project +COPY build.sbt ./ + +# Copy the rest of the source code COPY . . RUN sbt update compile stage RUN chmod a+rx /app/target/universal/stage/bin/* -FROM openjdk:11-jre-slim +FROM eclipse-temurin:11-jre-jammy WORKDIR /app diff --git a/src/main/scala/http/HttpClient.scala b/src/main/scala/http/HttpClient.scala index 75078e6..3167afc 100644 --- a/src/main/scala/http/HttpClient.scala +++ b/src/main/scala/http/HttpClient.scala @@ -52,8 +52,7 @@ class HttpClient { .withHeaders( Header.Raw(CIString("Accept"), "application/json"), Header.Raw(CIString("Content-Type"), "application/json"), - Header.Raw(CIString("User-Agent"), "watchlistarr/1.0"), - Header.Raw(CIString("Host"), host) + Header.Raw(CIString("User-Agent"), "watchlistarr/1.0") ) val requestWithApiKey = apiKey.fold(baseRequest)(key => baseRequest.withHeaders( From 9ae0922217ddc5b63f07af7cd31a47bfcbf669a5 Mon Sep 17 00:00:00 2001 From: fezster Date: Tue, 14 Apr 2026 17:45:38 +0100 Subject: [PATCH 2/5] Add radarr.availability config option and startup version logging Adds RADARR_AVAILABILITY env var to control movie availability in Radarr. When set (e.g. RADARR_AVAILABILITY=announced), the minimumAvailability field is included in the POST payload to /api/v3/movie. Omitted when not set to avoid null serialization. Also adds startup version log. --- build.sbt | 9 +++++++- docker/entrypoint.sh | 4 ++++ src/main/scala/Server.scala | 11 +++++++++ .../scala/configuration/Configuration.scala | 1 + .../configuration/ConfigurationRedactor.scala | 1 + .../configuration/ConfigurationUtils.scala | 8 ++++--- src/main/scala/configuration/Keys.scala | 1 + src/main/scala/radarr/RadarrPost.scala | 23 +++++++++++++++++-- src/main/scala/radarr/RadarrUtils.scala | 1 + 9 files changed, 53 insertions(+), 6 deletions(-) diff --git a/build.sbt b/build.sbt index f7d005a..a479a48 100644 --- a/build.sbt +++ b/build.sbt @@ -3,7 +3,14 @@ ThisBuild / scalaVersion := "2.13.12" lazy val root = (project in file(".")) .settings( name := "watchlistarr", - assembly / mainClass := Some("Server") + version := "0.2.7", + assembly / mainClass := Some("Server"), + Compile / resourceGenerators += Def.task { + val file = (Compile / resourceManaged).value / "version.properties" + val content = s"version=${version.value}\n" + IO.write(file, content) + Seq(file) + }.taskValue ) val caseInsensitiveVersion = "1.4.0" diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index a6551ac..2b45d2f 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -35,6 +35,10 @@ if [ -n "$RADARR_ROOT_FOLDER" ]; then CMD+=("-Dradarr.rootFolder=$RADARR_ROOT_FOLDER") fi +if [ -n "$RADARR_AVAILABILITY" ]; then + CMD+=("-Dradarr.availability=$RADARR_AVAILABILITY") +fi + if [ -n "$PLEX_WATCHLIST_URL_1" ]; then CMD+=("-Dplex.watchlist1=$PLEX_WATCHLIST_URL_1") fi diff --git a/src/main/scala/Server.scala b/src/main/scala/Server.scala index 56938c7..9bd83c1 100644 --- a/src/main/scala/Server.scala +++ b/src/main/scala/Server.scala @@ -20,7 +20,18 @@ object Server extends IOApp { val configReader = FileAndSystemPropertyReader val httpClient = new HttpClient + val version = IO { + val props = new java.util.Properties() + val stream = getClass.getClassLoader.getResourceAsStream("version.properties") + if (stream != null) { + try props.load(stream) finally stream.close() + } + props.getProperty("version", "unknown") + } + for { + v <- version + _ <- IO(logger.info(s"watchlistarr v$v starting")) initialConfig <- ConfigurationUtils.create(configReader, httpClient) configRef <- Ref.of[IO, Configuration](initialConfig) result <- ( diff --git a/src/main/scala/configuration/Configuration.scala b/src/main/scala/configuration/Configuration.scala index f176788..1a9c020 100644 --- a/src/main/scala/configuration/Configuration.scala +++ b/src/main/scala/configuration/Configuration.scala @@ -27,6 +27,7 @@ case class RadarrConfiguration( radarrBaseUrl: Uri, radarrApiKey: String, radarrQualityProfileId: Int, + radarrAvailability: Option[String], radarrRootFolder: String, radarrBypassIgnored: Boolean, radarrTagIds: Set[Int] diff --git a/src/main/scala/configuration/ConfigurationRedactor.scala b/src/main/scala/configuration/ConfigurationRedactor.scala index c1ab3e1..4fc63d9 100644 --- a/src/main/scala/configuration/ConfigurationRedactor.scala +++ b/src/main/scala/configuration/ConfigurationRedactor.scala @@ -21,6 +21,7 @@ object ConfigurationRedactor { | radarrQualityProfileId: ${config.radarrConfiguration.radarrQualityProfileId} | radarrRootFolder: ${config.radarrConfiguration.radarrRootFolder} | radarrBypassIgnored: ${config.radarrConfiguration.radarrBypassIgnored} + | radarrAvailability: ${config.radarrConfiguration.radarrAvailability.getOrElse("not set")} | radarrTagIds: ${config.radarrConfiguration.radarrTagIds.mkString(",")} | | PlexConfiguration: diff --git a/src/main/scala/configuration/ConfigurationUtils.scala b/src/main/scala/configuration/ConfigurationUtils.scala index dfff352..018fc89 100644 --- a/src/main/scala/configuration/ConfigurationUtils.scala +++ b/src/main/scala/configuration/ConfigurationUtils.scala @@ -34,7 +34,7 @@ object ConfigurationUtils { sonarrBypassIgnored = configReader.getConfigOption(Keys.sonarrBypassIgnored).exists(_.toBoolean) sonarrSeasonMonitoring = configReader.getConfigOption(Keys.sonarrSeasonMonitoring).getOrElse("all") radarrConfig <- getRadarrConfig(configReader, client) - (radarrBaseUrl, radarrApiKey, radarrQualityProfileId, radarrRootFolder, radarrTagIds) = radarrConfig + (radarrBaseUrl, radarrApiKey, radarrQualityProfileId, radarrRootFolder, radarrTagIds, radarrAvailability) = radarrConfig radarrBypassIgnored = configReader.getConfigOption(Keys.radarrBypassIgnored).exists(_.toBoolean) plexTokens = getPlexTokens(configReader) skipFriendSync = configReader.getConfigOption(Keys.skipFriendSync).flatMap(_.toBooleanOption).getOrElse(false) @@ -64,6 +64,7 @@ object ConfigurationUtils { radarrBaseUrl, radarrApiKey, radarrQualityProfileId, + radarrAvailability, radarrRootFolder, radarrBypassIgnored, radarrTagIds @@ -159,7 +160,7 @@ object ConfigurationUtils { private def getRadarrConfig( configReader: ConfigurationReader, client: HttpClient - ): IO[(Uri, String, Int, String, Set[Int])] = { + ): IO[(Uri, String, Int, String, Set[Int], Option[String])] = { val apiKey = configReader.getConfigOption(Keys.radarrApiKey).getOrElse(throwError("Unable to find radarr API key")) val configuredUrl = configReader.getConfigOption(Keys.radarrBaseUrl) val possibleUrls: Seq[String] = @@ -187,7 +188,8 @@ object ConfigurationUtils { .getConfigOption(Keys.radarrTags) .map(getTagIdsFromConfig(client, url, apiKey)) .getOrElse(IO.pure(Set.empty[Int])) - } yield (url, apiKey, qualityProfileId, rootFolder, tagIds) + availability = configReader.getConfigOption(Keys.radarrAvailability) + } yield (url, apiKey, qualityProfileId, rootFolder, tagIds, availability) } private def getTagIdsFromConfig(client: HttpClient, url: Uri, apiKey: String)(tags: String): IO[Set[Int]] = { diff --git a/src/main/scala/configuration/Keys.scala b/src/main/scala/configuration/Keys.scala index 5e7ec4d..ee89448 100644 --- a/src/main/scala/configuration/Keys.scala +++ b/src/main/scala/configuration/Keys.scala @@ -14,6 +14,7 @@ private[configuration] object Keys { val radarrBaseUrl = "radarr.baseUrl" val radarrApiKey = "radarr.apikey" val radarrQualityProfile = "radarr.qualityProfile" + val radarrAvailability = "radarr.availability" val radarrRootFolder = "radarr.rootFolder" val radarrBypassIgnored = "radarr.bypassIgnored" val radarrTags = "radarr.tags" diff --git a/src/main/scala/radarr/RadarrPost.scala b/src/main/scala/radarr/RadarrPost.scala index 7a38406..2926479 100644 --- a/src/main/scala/radarr/RadarrPost.scala +++ b/src/main/scala/radarr/RadarrPost.scala @@ -1,10 +1,29 @@ package radarr -private case class RadarrPost( +import io.circe.syntax._ +import io.circe.{Encoder, Json, JsonObject} + +case class RadarrPost( title: String, tmdbId: Long, qualityProfileId: Int = 6, rootFolderPath: String, - addOptions: AddOptions = AddOptions(), + minimumAvailability: Option[String] = None, tags: List[Int] = List.empty[Int] ) + +object RadarrPost { + implicit val encoder: Encoder[RadarrPost] = Encoder.instance { post => + var fields = List[(String, Json)]( + "title" -> post.title.asJson, + "tmdbId" -> post.tmdbId.asJson, + "qualityProfileId" -> post.qualityProfileId.asJson, + "rootFolderPath" -> post.rootFolderPath.asJson, + "tags" -> post.tags.asJson + ) + post.minimumAvailability.foreach { avail => + fields = fields :+ ("minimumAvailability" -> avail.asJson) + } + Json.fromJsonObject(JsonObject.fromIterable(fields)) + } +} diff --git a/src/main/scala/radarr/RadarrUtils.scala b/src/main/scala/radarr/RadarrUtils.scala index 315f17b..a19c4d2 100644 --- a/src/main/scala/radarr/RadarrUtils.scala +++ b/src/main/scala/radarr/RadarrUtils.scala @@ -35,6 +35,7 @@ trait RadarrUtils extends RadarrConversions { item.getTmdbId.getOrElse(0L), config.radarrQualityProfileId, config.radarrRootFolder, + minimumAvailability = config.radarrAvailability, tags = config.radarrTagIds.toList ) From 07bb805c8fb8ad175e528c74f0d1f826c72fded3 Mon Sep 17 00:00:00 2001 From: fezster Date: Wed, 15 Apr 2026 15:06:50 +0100 Subject: [PATCH 3/5] Normalize blank radarrAvailability to avoid sending invalid payload values. Line 191 accepts raw Option[String] without normalization. If the runtime injects an empty property value (e.g., -Dradarr.availability=), this becomes Some(""). This value propagates through to RadarrPost.scala (line 24-26), where it serializes as "minimumAvailability": "" in the JSON payload, which the Radarr API does not accept. --- src/main/scala/configuration/ConfigurationUtils.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/scala/configuration/ConfigurationUtils.scala b/src/main/scala/configuration/ConfigurationUtils.scala index 018fc89..b81d350 100644 --- a/src/main/scala/configuration/ConfigurationUtils.scala +++ b/src/main/scala/configuration/ConfigurationUtils.scala @@ -188,7 +188,7 @@ object ConfigurationUtils { .getConfigOption(Keys.radarrTags) .map(getTagIdsFromConfig(client, url, apiKey)) .getOrElse(IO.pure(Set.empty[Int])) - availability = configReader.getConfigOption(Keys.radarrAvailability) + availability = configReader.getConfigOption(Keys.radarrAvailability).map(_.trim).filter(_.nonEmpty) } yield (url, apiKey, qualityProfileId, rootFolder, tagIds, availability) } From 3253fcc2caf485e66f9a1ee3ab3a4e8f69223853 Mon Sep 17 00:00:00 2001 From: fezster Date: Thu, 6 Aug 2026 11:25:44 +0100 Subject: [PATCH 4/5] Add JSON payload logging for Radarr and Sonarr POST requests Co-authored-by: Qwen-Coder --- src/main/scala/radarr/RadarrUtils.scala | 4 +++- src/main/scala/sonarr/SonarrUtils.scala | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/scala/radarr/RadarrUtils.scala b/src/main/scala/radarr/RadarrUtils.scala index a19c4d2..1e8fff0 100644 --- a/src/main/scala/radarr/RadarrUtils.scala +++ b/src/main/scala/radarr/RadarrUtils.scala @@ -77,7 +77,8 @@ trait RadarrUtils extends RadarrConversions { private def postToArr[T: Decoder]( client: HttpClient - )(baseUrl: Uri, apiKey: String, endpoint: String)(payload: Json): EitherT[IO, Throwable, T] = + )(baseUrl: Uri, apiKey: String, endpoint: String)(payload: Json): EitherT[IO, Throwable, T] = { + logger.info(s"Sending JSON to Radarr: $payload") for { response <- EitherT( client.httpRequest(Method.POST, baseUrl / "api" / "v3" / endpoint, Some(apiKey), Some(payload)) @@ -85,6 +86,7 @@ trait RadarrUtils extends RadarrConversions { maybeDecoded <- EitherT.pure[IO, Throwable](response.as[T]) decoded <- EitherT.fromOption[IO](maybeDecoded.toOption, new Throwable("Unable to decode response from Radarr")) } yield decoded + } private def deleteToArr( client: HttpClient diff --git a/src/main/scala/sonarr/SonarrUtils.scala b/src/main/scala/sonarr/SonarrUtils.scala index eeeaf3d..01d55dd 100644 --- a/src/main/scala/sonarr/SonarrUtils.scala +++ b/src/main/scala/sonarr/SonarrUtils.scala @@ -91,7 +91,8 @@ trait SonarrUtils extends SonarrConversions { private def postToArr[T: Decoder]( client: HttpClient - )(baseUrl: Uri, apiKey: String, endpoint: String)(payload: Json): EitherT[IO, Throwable, T] = + )(baseUrl: Uri, apiKey: String, endpoint: String)(payload: Json): EitherT[IO, Throwable, T] = { + logger.info(s"Sending JSON to Sonarr: $payload") for { response <- EitherT( client.httpRequest(Method.POST, baseUrl / "api" / "v3" / endpoint, Some(apiKey), Some(payload)) @@ -99,4 +100,5 @@ trait SonarrUtils extends SonarrConversions { maybeDecoded <- EitherT.pure[IO, Throwable](response.as[T]) decoded <- EitherT.fromOption[IO](maybeDecoded.toOption, new Throwable("Unable to decode response from Sonarr")) } yield decoded + } } From bbebdc2970d84393997327c861813d8e6ce01d78 Mon Sep 17 00:00:00 2001 From: fezster Date: Thu, 6 Aug 2026 11:54:24 +0100 Subject: [PATCH 5/5] Add addOptions with searchForMovie to Radarr POST requests Co-authored-by: Qwen-Coder --- src/main/scala/radarr/AddOptions.scala | 2 +- src/main/scala/radarr/RadarrPost.scala | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/scala/radarr/AddOptions.scala b/src/main/scala/radarr/AddOptions.scala index 5af6002..c85487b 100644 --- a/src/main/scala/radarr/AddOptions.scala +++ b/src/main/scala/radarr/AddOptions.scala @@ -1,3 +1,3 @@ package radarr -private case class AddOptions(searchForMovie: Boolean = true) +case class AddOptions(searchForMovie: Boolean = true) diff --git a/src/main/scala/radarr/RadarrPost.scala b/src/main/scala/radarr/RadarrPost.scala index 2926479..8d4fee7 100644 --- a/src/main/scala/radarr/RadarrPost.scala +++ b/src/main/scala/radarr/RadarrPost.scala @@ -1,5 +1,6 @@ package radarr +import io.circe.generic.auto._ import io.circe.syntax._ import io.circe.{Encoder, Json, JsonObject} @@ -9,7 +10,8 @@ case class RadarrPost( qualityProfileId: Int = 6, rootFolderPath: String, minimumAvailability: Option[String] = None, - tags: List[Int] = List.empty[Int] + tags: List[Int] = List.empty[Int], + addOptions: AddOptions = AddOptions() ) object RadarrPost { @@ -24,6 +26,7 @@ object RadarrPost { post.minimumAvailability.foreach { avail => fields = fields :+ ("minimumAvailability" -> avail.asJson) } + fields = fields :+ ("addOptions" -> post.addOptions.asJson) Json.fromJsonObject(JsonObject.fromIterable(fields)) } }