Skip to content
Open
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
9 changes: 8 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
9 changes: 7 additions & 2 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was this necessary to get the image to build? If not removing these changes would make the change smaller and easier to get merged (although it doesn't appear like the size of the change is the issue and this is abandoned)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, was necessary as openjdk has been deprecated.


WORKDIR /app

Expand Down
4 changes: 4 additions & 0 deletions docker/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions src/main/scala/Server.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 <- (
Expand Down
1 change: 1 addition & 0 deletions src/main/scala/configuration/Configuration.scala
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ case class RadarrConfiguration(
radarrBaseUrl: Uri,
radarrApiKey: String,
radarrQualityProfileId: Int,
radarrAvailability: Option[String],
radarrRootFolder: String,
radarrBypassIgnored: Boolean,
radarrTagIds: Set[Int]
Expand Down
1 change: 1 addition & 0 deletions src/main/scala/configuration/ConfigurationRedactor.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 5 additions & 3 deletions src/main/scala/configuration/ConfigurationUtils.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -64,6 +64,7 @@ object ConfigurationUtils {
radarrBaseUrl,
radarrApiKey,
radarrQualityProfileId,
radarrAvailability,
radarrRootFolder,
radarrBypassIgnored,
radarrTagIds
Expand Down Expand Up @@ -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] =
Expand Down Expand Up @@ -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).map(_.trim).filter(_.nonEmpty)
} yield (url, apiKey, qualityProfileId, rootFolder, tagIds, availability)
Comment on lines +191 to +192

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

# Show the supported Radarr values.
curl -fsSL \
  'https://raw.githubusercontent.com/Radarr/Radarr/develop/src/Radarr.Api.V3/openapi.json' |
  jq -r '.components.schemas.MovieStatusType.enum[]'

# Trace validation and error-reporting paths.
rg -n -C4 --glob '*.scala' \
  'radarrAvailability|minimumAvailability|Sent .* to Radarr|Received warning for sending' .

Repository: nylonee/watchlistarr

Length of output: 9455


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

# Inspect imports/dependencies and exact call sites relevant to validation and HTTP response handling.
rg -n --glob '*.scala' 'case class RadarrConfiguration|def postToArr|EitherT|Client|httpClient|raiseFailures' .
git ls-files '*build*' | sed -n '1,120p'
cat -n src/main/scala/configuration/ConfigurationUtils.scala | sed -n '160,200p'
cat -n src/main/scala/configuration/Configuration.scala | sed -n '1,80p'
if [ -f src/main/scala/radarr/RadarrUtils.scala ]; then
  cat -n src/main/scala/radarr/RadarrUtils.scala | sed -n '1,70p'
fi
if [ -f src/main/scala/radarr/RadarrPost.scala ]; then
  cat -n src/main/scala/radarr/RadarrPost.scala | sed -n '1,40p'
fi

# Check whether the repository has any existing enum-like helper for Radarr availability.
rg -n --glob '*.scala' 'tba|announced|inCinemas|released|deleted|MovieStatusType|RadarrAvailability|radarrAvailability' .

Repository: nylonee/watchlistarr

Length of output: 35372


Reject unsupported Radarr availability values during configuration loading.

radarr.availability currently accepts any non-empty string and sends it as minimumAvailability. Radarr only supports tba, announced, inCinemas, released, and deleted. A typo or unsupported value will silently be logged as a warning, then reported as successfully sent. Validate this field before startup and fail with a clear error for invalid values.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/scala/configuration/ConfigurationUtils.scala` around lines 191 -
192, Update the Radarr configuration loading flow around the availability value
in ConfigurationUtils so, after trimming and ignoring empty values, only tba,
announced, inCinemas, released, and deleted are accepted. Reject unsupported
non-empty values during startup with a clear configuration error, while
preserving the existing optional behavior for absent or blank availability.

}

private def getTagIdsFromConfig(client: HttpClient, url: Uri, apiKey: String)(tags: String): IO[Set[Int]] = {
Expand Down
1 change: 1 addition & 0 deletions src/main/scala/configuration/Keys.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 1 addition & 2 deletions src/main/scala/http/HttpClient.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion src/main/scala/radarr/AddOptions.scala
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
package radarr

private case class AddOptions(searchForMovie: Boolean = true)
case class AddOptions(searchForMovie: Boolean = true)
28 changes: 25 additions & 3 deletions src/main/scala/radarr/RadarrPost.scala
Original file line number Diff line number Diff line change
@@ -1,10 +1,32 @@
package radarr

private case class RadarrPost(
import io.circe.generic.auto._
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(),
tags: List[Int] = List.empty[Int]
minimumAvailability: Option[String] = None,
tags: List[Int] = List.empty[Int],
addOptions: AddOptions = AddOptions()
)

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)
}
fields = fields :+ ("addOptions" -> post.addOptions.asJson)
Json.fromJsonObject(JsonObject.fromIterable(fields))
}
}
Comment on lines +17 to +32

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

rg -n -C4 --glob '*.scala' \
  'AddOptions|addOptions|searchForMovie|RadarrPost' .

curl -fsSL \
  'https://raw.githubusercontent.com/Radarr/Radarr/develop/src/Radarr.Api.V3/openapi.json' |
  jq -e '.components.schemas.AddMovieOptions.properties.searchForMovie'

Repository: nylonee/watchlistarr

Length of output: 7623


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- RadarrPost.scala ---'
cat -n src/main/scala/radarr/RadarrPost.scala

printf '%s\n' '--- RadarrUtils.scala relevant section ---'
cat -n src/main/scala/radarr/RadarrUtils.scala

printf '%s\n' '--- Radarr delete post addOptions (context) ---'
cat -n src/main/scala/radarr/RadarrDelete.scala

printf '%s\n' '--- json output from current encoder ---'
python3 - <<'PY'
from pathlib import Path
src = Path('src/main/scala/radarr/RadarrPost.scala').read_text()
print('addOptions field in RadarrPost.scala:', 'addOptions' in src)
print('searchForMovie in RadarrPost.scala:', 'searchForMovie' in src)
PY

Repository: nylonee/watchlistarr

Length of output: 6063


🌐 Web query:

Radarr API add movie addOptions searchForMovie default true

💡 Result:

To trigger an automatic search when adding a movie via the Radarr API, you must include the addOptions object in your POST request body with the searchForMovie property set to true [1][2][3]. The correct JSON structure for the request body is as follows: { "title": "Movie Title", "tmdbId": 12345, "qualityProfileId": 1, "rootFolderPath": "/path/to/movies", "monitored": true, "addOptions": { "searchForMovie": true } } When adding a movie via the POST /api/v3/movie endpoint, the searchForMovie option is not set to true by default [1]. Including this option explicitly in the payload instructs Radarr to perform an immediate search for the movie upon addition [1][2]. This mechanism is widely used to replicate the behavior available in the Radarr Web UI where users can check an option to automatically search for the movie once it is added [1].

Citations:


Set searchForMovie explicitly when adding movies to Radarr.

RadarrPost no longer sends addOptions, so Radarr will not perform the automatic search after POST /api/v3/movie. Add addOptions: { "searchForMovie": true } unless the target integration is not intended to search immediately after adding a movie.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/scala/radarr/RadarrPost.scala` around lines 15 - 29, Update the
RadarrPost encoder to include an addOptions object containing searchForMovie set
to true in the serialized movie payload. Preserve the existing fields and
optional minimumAvailability handling while ensuring every added movie requests
an immediate search.

5 changes: 4 additions & 1 deletion src/main/scala/radarr/RadarrUtils.scala
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ trait RadarrUtils extends RadarrConversions {
item.getTmdbId.getOrElse(0L),
config.radarrQualityProfileId,
config.radarrRootFolder,
minimumAvailability = config.radarrAvailability,
tags = config.radarrTagIds.toList
)

Expand Down Expand Up @@ -76,14 +77,16 @@ 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))
)
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
Expand Down
4 changes: 3 additions & 1 deletion src/main/scala/sonarr/SonarrUtils.scala
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,14 @@ 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")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Do not log the full payload at INFO level.

Line [95] writes the complete Sonarr JSON payload to application logs. The payload includes media metadata and configuration values such as titles, identifiers, root folders, profile IDs, and tags. Retained or shared logs can expose user library information and filesystem paths.

Log only the endpoint and request status at INFO level. Use a redacted payload at DEBUG level only when detailed diagnostics are required.

Proposed fix
-    logger.info(s"Sending JSON to Sonarr: $payload")
+    logger.info(s"Sending request to Sonarr endpoint: $endpoint")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
logger.info(s"Sending JSON to Sonarr: $payload")
logger.info(s"Sending request to Sonarr endpoint: $endpoint")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/scala/sonarr/SonarrUtils.scala` at line 95, Update the Sonarr
request logging around the logger.info call to avoid emitting the full payload
at INFO level; log only the endpoint and request status there, and move any
detailed payload diagnostics to DEBUG using a redacted payload.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Verify that the Sonarr log is sequenced inside the returned effect.
rg -n -C 8 'logger\.info\(s"Sending JSON to Sonarr|def postToArr|client\.httpRequest' src/main/scala

Repository: nylonee/watchlistarr

Length of output: 17884


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== SonarrUtils context =="
sed -n '1,140p' src/main/scala/sonarr/SonarrUtils.scala

echo
echo "== postToArr call sites =="
rg -n -C 4 'postToArr\b|EitherT\(client\.httpRequest' src/main/scala/sonarr src/main/scala/radarr src/main/scala

echo
echo "== Cats IO/EitherT availability in project files =="
rg -n 'cats-effect|typelevel|cats' build.sbt project . --glob '!target/**' --glob '!node_modules/**' 2>/dev/null || true

Repository: nylonee/watchlistarr

Length of output: 21767


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Use an embedded REPL-free behavioral probe that treats source text as data.
# It checks whether the INFO log is syntactically outside the IO/EitherT effect body.
python3 - <<'PY'
from pathlib import Path
import re

path = Path('src/main/scala/sonarr/SonarrUtils.scala')
src = path.read_text()

def extract_function_method(method_name: str):
    # Find the declaration and then consume balanced braces, with support for string interpolation chars.
    # For demonstration, the SonarrUtils method is simple enough that string literal balance is sufficient.
    pattern = re.compile(f'\\bdef\\s+{re.escape(method_name)}\\b\\s*\\([^)]*\\)\\s*[^=]*=\\s*\\n')
    m = pattern.search(src)
    if not m:
        return None
    start = m.start()
    i = src.find('{', m.end())
    if i == -1:
        return None
    depth = 0
    squote = dquote = raw = rawd = False
    while i < len(src):
        c = src[i]
        cc = src[i:i+2]
        if raw:
            if c == '`': raw = False
        elif rawd:
            if src.startswith('"""', i): rawd = False; i += 2; continue
        elif cc == '`'': raw = True; i += 1; continue
        elif cc == '"""': rawd = True; continue
        elif c == '"' and not dquote:
            dquote = True
        elif c == '"' and dquote:
            dquote = False
        elif cc == '{{': i += 1; continue
        elif cc == '}}': i += 1; continue
        else:
            if c == '{': depth += 1
            elif c == '}':
                depth -= 1
                if depth == 0:
                    end = i + 1
                    break
        i += 1
    return src[start:end], start, end

body, start, end = extract_function_method('postToArr')
print(f'function_offset_start={start}')
print(f'log_offset={src.find("logger.info(s\"Sending JSON to Sonarr:", start, end)}')
print(f'for_offset={src.find("for {", start, end)}')

inside_brace = body.index('logger.info(s"Sending JSON to Sonarr:') < body.index('for {')
# The method assigns into a for-comprehension; the returned effect starts at the for, not before it.
returned_effect_start = body.index('for {')
print(f'log_before_for={inside_brace}')
print(f'log_inside_returned_effect_body={inside_brace}')
print(f'postToArr_return_effect_starts_at_for={returned_effect_start > start}')
PY

Repository: nylonee/watchlistarr

Length of output: 332


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Exact Sonarr log position =="
python3 - <<'PY'
from pathlib import Path
text = Path('src/main/scala/sonarr/SonarrUtils.scala').read_text()
needle = 'logger.info(s"Sending JSON to Sonarr:'
start = text.find(needle, text.index('private def postToArr'))
end = text.index('\n}', start) + 2
lines = text.splitlines()
for_offset = text.find('for {', text.indexOf('private def postToArr') if hasattr(text,'indexOf') else text.index('private def postToArr'))
if start != -1:
    line_no = text[:text.find(needle, text.index('private def postToArr'))].count('\n') + 1
    print(f'log_at_line={line_no}')
    print(f'for_at_block={text.find("for {", start)}')
PY

echo
echo "== Read-only line-number sanity check from git diff against local file =="
(git diff -- src/main/scala/sonarr/SonarrUtils.scala | sed -n '1,80p' || true)
sed -n '92,103p' src/main/scala/sonarr/SonarrUtils.scala

Repository: nylonee/watchlistarr

Length of output: 869


Move the Sonarr request log inside the IO effect.

Line 95 logs while postToArr builds the EitherT, before the request effect runs. A lazy failed or canceled IO can still log Sending JSON to Sonarr:... and expose the full payload in retained logs.

Proposed fix
   )(baseUrl: Uri, apiKey: String, endpoint: String)(payload: Json): EitherT[IO, Throwable, T] = {
-    logger.info(s"Sending JSON to Sonarr: $payload")
     for {
+      _ <- EitherT.liftF[IO, Throwable, Unit](IO(logger.info(s"Sending JSON to Sonarr: $payload")))
       response <- EitherT(
         client.httpRequest(Method.POST, baseUrl / "api" / "v3" / endpoint, Some(apiKey), Some(payload))
       )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
logger.info(s"Sending JSON to Sonarr: $payload")
)(baseUrl: Uri, apiKey: String, endpoint: String)(payload: Json): EitherT[IO, Throwable, T] = {
for {
_ <- EitherT.liftF[IO, Throwable, Unit](IO(logger.info(s"Sending JSON to Sonarr: $payload")))
response <- EitherT(
client.httpRequest(Method.POST, baseUrl / "api" / "v3" / endpoint, Some(apiKey), Some(payload))
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/scala/sonarr/SonarrUtils.scala` at line 95, Move the Sonarr request
log in postToArr into the IO effect that performs the request, ensuring it
executes only when the request is run rather than while the EitherT is built.
Preserve the existing request behavior and avoid logging the payload during lazy
construction, failure, or cancellation.

for {
response <- EitherT(
client.httpRequest(Method.POST, baseUrl / "api" / "v3" / endpoint, Some(apiKey), Some(payload))
)
maybeDecoded <- EitherT.pure[IO, Throwable](response.as[T])
decoded <- EitherT.fromOption[IO](maybeDecoded.toOption, new Throwable("Unable to decode response from Sonarr"))
} yield decoded
}
}