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
3 changes: 3 additions & 0 deletions src/main/resources/config-template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@
# tags:
# - watchlistarr

## Tag to use in Radarr to bypass movie deletion
# skipTag: "keep"


#################################################################
## Plex Configuration
Expand Down
25 changes: 24 additions & 1 deletion src/main/scala/PlexTokenDeleteSync.scala
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,30 @@ object PlexTokenDeleteSync extends PlexUtils with SonarrUtils with RadarrUtils {

private def deleteMovie(client: HttpClient, config: Configuration)(movie: Item): EitherT[IO, Throwable, Unit] =
if (config.deleteConfiguration.movieDeleting) {
deleteFromRadarr(client, config.radarrConfiguration, config.deleteConfiguration.deleteFiles)(movie)
val tmdbId = movie.getTmdbId.getOrElse {
logger.warn(s"Unable to extract Tmdb ID from movie to delete: $movie")
0L
}
Comment on lines +72 to +75
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Consider enhancing error handling for missing TMDB ID.

Using a default TMDB ID of 0L when extraction fails could lead to unexpected behavior. Consider returning an error instead.

-      val tmdbId = movie.getTmdbId.getOrElse {
-        logger.warn(s"Unable to extract Tmdb ID from movie to delete: $movie")
-        0L
-      }
+      movie.getTmdbId.toRight(new IllegalArgumentException(s"Unable to extract TMDB ID from movie: $movie")).toEitherT[IO]
📝 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
val tmdbId = movie.getTmdbId.getOrElse {
logger.warn(s"Unable to extract Tmdb ID from movie to delete: $movie")
0L
}
movie.getTmdbId.toRight(new IllegalArgumentException(s"Unable to extract TMDB ID from movie: $movie")).toEitherT[IO]


for {
eitherShouldSkip <- hasSkipMovieTag(client)(
config.radarrConfiguration.radarrApiKey,
config.radarrConfiguration.radarrBaseUrl,
config.radarrConfiguration.radarrSkipTag,
tmdbId
).attempt
_ <- eitherShouldSkip match {
case Left(error) =>
EitherT.liftF[IO, Throwable, Unit](IO(logger.error(s"Error in hasSkipMovieTag: ${error.getMessage}", error)))
case Right(shouldSkip) =>
if (shouldSkip) {
logger.info(s"Skipping deletion of movie [${movie}] due to Skip Tag [${config.radarrConfiguration.radarrSkipTag}]")
EitherT.pure[IO, Throwable](None)
} else {
deleteFromRadarr(client, config.radarrConfiguration, config.deleteConfiguration.deleteFiles)(movie)
}
}
Comment on lines +84 to +94
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

⚠️ Potential issue

Fix return type mismatch in skip tag handling.

The EitherT.pure[IO, Throwable](None) returns Option[Unit] which doesn't match the expected return type.

-              EitherT.pure[IO, Throwable](None)
+              EitherT.pure[IO, Throwable](())

Also, consider improving error handling by propagating the error instead of just logging it.

-            EitherT.liftF[IO, Throwable, Unit](IO(logger.error(s"Error in hasSkipMovieTag: ${error.getMessage}", error)))
+            EitherT.leftT[IO, Unit](error)
📝 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
_ <- eitherShouldSkip match {
case Left(error) =>
EitherT.liftF[IO, Throwable, Unit](IO(logger.error(s"Error in hasSkipMovieTag: ${error.getMessage}", error)))
case Right(shouldSkip) =>
if (shouldSkip) {
logger.info(s"Skipping deletion of movie [${movie}] due to Skip Tag [${config.radarrConfiguration.radarrSkipTag}]")
EitherT.pure[IO, Throwable](None)
} else {
deleteFromRadarr(client, config.radarrConfiguration, config.deleteConfiguration.deleteFiles)(movie)
}
}
_ <- eitherShouldSkip match {
case Left(error) =>
- EitherT.liftF[IO, Throwable, Unit](IO(logger.error(s"Error in hasSkipMovieTag: ${error.getMessage}", error)))
+ EitherT.leftT[IO, Unit](error)
case Right(shouldSkip) =>
if (shouldSkip) {
logger.info(s"Skipping deletion of movie [${movie}] due to Skip Tag [${config.radarrConfiguration.radarrSkipTag}]")
- EitherT.pure[IO, Throwable](None)
+ EitherT.pure[IO, Throwable](())
} else {
deleteFromRadarr(client, config.radarrConfiguration, config.deleteConfiguration.deleteFiles)(movie)
}
}

} yield eitherShouldSkip
} else {
logger.info(s"Found movie \"${movie.title}\" which is not watchlisted on Plex")
EitherT.pure[IO, Throwable](())
Expand Down
3 changes: 2 additions & 1 deletion src/main/scala/configuration/Configuration.scala
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ case class RadarrConfiguration(
radarrQualityProfileId: Int,
radarrRootFolder: String,
radarrBypassIgnored: Boolean,
radarrTagIds: Set[Int]
radarrTagIds: Set[Int],
radarrSkipTag: String
)

case class PlexConfiguration(
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 @@ -22,6 +22,7 @@ object ConfigurationRedactor {
| radarrRootFolder: ${config.radarrConfiguration.radarrRootFolder}
| radarrBypassIgnored: ${config.radarrConfiguration.radarrBypassIgnored}
| radarrTagIds: ${config.radarrConfiguration.radarrTagIds.mkString(",")}
| radarrSkipTag: ${config.radarrConfiguration.radarrSkipTag}
|
| PlexConfiguration:
| plexWatchlistUrls: ${config.plexConfiguration.plexWatchlistUrls.mkString(", ")}
Expand Down
10 changes: 6 additions & 4 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, radarrSkipTag) = radarrConfig
radarrBypassIgnored = configReader.getConfigOption(Keys.radarrBypassIgnored).exists(_.toBoolean)
plexTokens = getPlexTokens(configReader)
skipFriendSync = configReader.getConfigOption(Keys.skipFriendSync).flatMap(_.toBooleanOption).getOrElse(false)
Expand Down Expand Up @@ -66,7 +66,8 @@ object ConfigurationUtils {
radarrQualityProfileId,
radarrRootFolder,
radarrBypassIgnored,
radarrTagIds
radarrTagIds,
radarrSkipTag
),
PlexConfiguration(
plexWatchlistUrls,
Expand Down Expand Up @@ -159,8 +160,9 @@ object ConfigurationUtils {
private def getRadarrConfig(
configReader: ConfigurationReader,
client: HttpClient
): IO[(Uri, String, Int, String, Set[Int])] = {
): IO[(Uri, String, Int, String, Set[Int], String)] = {
val apiKey = configReader.getConfigOption(Keys.radarrApiKey).getOrElse(throwError("Unable to find radarr API key"))
val skipTag = configReader.getConfigOption(Keys.radarrSkipTag).orNull
Comment on lines +163 to +165
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Replace orNull with Option for safer null handling.

Using orNull is not idiomatic Scala and could lead to runtime null pointer exceptions. Consider using Option instead.

Apply this diff to improve null safety:

-  ): IO[(Uri, String, Int, String, Set[Int], String)] = {
+  ): IO[(Uri, String, Int, String, Set[Int], Option[String])] = {
     val apiKey = configReader.getConfigOption(Keys.radarrApiKey).getOrElse(throwError("Unable to find radarr API key"))
-    val skipTag = configReader.getConfigOption(Keys.radarrSkipTag).orNull
+    val skipTag = configReader.getConfigOption(Keys.radarrSkipTag)
📝 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
): IO[(Uri, String, Int, String, Set[Int], String)] = {
val apiKey = configReader.getConfigOption(Keys.radarrApiKey).getOrElse(throwError("Unable to find radarr API key"))
val skipTag = configReader.getConfigOption(Keys.radarrSkipTag).orNull
): IO[(Uri, String, Int, String, Set[Int], Option[String])] = {
val apiKey = configReader.getConfigOption(Keys.radarrApiKey).getOrElse(throwError("Unable to find radarr API key"))
val skipTag = configReader.getConfigOption(Keys.radarrSkipTag)

val configuredUrl = configReader.getConfigOption(Keys.radarrBaseUrl)
val possibleUrls: Seq[String] =
configuredUrl.map("http://" + _).toSeq ++ configuredUrl.toSeq ++ possibleLocalHosts.map(_ + ":7878")
Expand All @@ -187,7 +189,7 @@ object ConfigurationUtils {
.getConfigOption(Keys.radarrTags)
.map(getTagIdsFromConfig(client, url, apiKey))
.getOrElse(IO.pure(Set.empty[Int]))
} yield (url, apiKey, qualityProfileId, rootFolder, tagIds)
} yield (url, apiKey, qualityProfileId, rootFolder, tagIds, skipTag)
}

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 @@ -17,6 +17,7 @@ private[configuration] object Keys {
val radarrRootFolder = "radarr.rootFolder"
val radarrBypassIgnored = "radarr.bypassIgnored"
val radarrTags = "radarr.tags"
val radarrSkipTag = "radarr.skipTag"

val plexWatchlist1 = "plex.watchlist1"
val plexWatchlist2 = "plex.watchlist2"
Expand Down
2 changes: 1 addition & 1 deletion src/main/scala/radarr/RadarrConversions.scala
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@ private[radarr] trait RadarrConversions {
)

def toItem(movie: RadarrMovieExclusion): Item = toItem(
RadarrMovie(movie.movieTitle, movie.imdbId, movie.tmdbId, movie.id)
RadarrMovie(movie.movieTitle, movie.imdbId, movie.tmdbId, movie.id, List.empty)
)
}
2 changes: 1 addition & 1 deletion src/main/scala/radarr/RadarrMovie.scala
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
package radarr

private[radarr] case class RadarrMovie(title: String, imdbId: Option[String], tmdbId: Option[Long], id: Long)
private[radarr] case class RadarrMovie(title: String, imdbId: Option[String], tmdbId: Option[Long], id: Long, tags: List[Int])
3 changes: 3 additions & 0 deletions src/main/scala/radarr/RadarrTag.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package radarr

private[radarr] case class RadarrTag(label: String, id: Int)
42 changes: 38 additions & 4 deletions src/main/scala/radarr/RadarrUtils.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,11 @@ package radarr

import cats.data.EitherT
import cats.effect.IO
import cats.implicits._
import configuration.RadarrConfiguration
import http.HttpClient
import io.circe.{Decoder, Json}
import io.circe.generic.auto._
import io.circe.syntax.EncoderOps
import io.circe.{Decoder, Json}
import model.Item
import org.http4s.{MalformedMessageBodyFailure, Method, Uri}
import org.slf4j.LoggerFactory
Expand All @@ -16,6 +15,8 @@ trait RadarrUtils extends RadarrConversions {

private val logger = LoggerFactory.getLogger(getClass)

private var skipTagId: Int = -1
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Consider thread-safety for skipTagId cache.

The mutable skipTagId variable could lead to race conditions in a concurrent environment.

Consider using an atomic reference or synchronization:

-  private var skipTagId: Int = -1
+  private val skipTagId = new java.util.concurrent.atomic.AtomicInteger(-1)
📝 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
private var skipTagId: Int = -1
private val skipTagId = new java.util.concurrent.atomic.AtomicInteger(-1)


protected def fetchMovies(
client: HttpClient
)(apiKey: String, baseUrl: Uri, bypass: Boolean): EitherT[IO, Throwable, Set[Item]] =
Expand Down Expand Up @@ -65,14 +66,47 @@ trait RadarrUtils extends RadarrConversions {
}
}

private def getSkipTagId(client: HttpClient)(
apiKey: String,
baseUrl: Uri,
skipTag: String
): EitherT[IO, Throwable, Int] = {
if (this.skipTagId >= 0) return EitherT.pure[IO, Throwable](this.skipTagId)
for {
allRadarrTags <- getToArr[List[RadarrTag]](client)(baseUrl, apiKey, "tag")
skipTagId = allRadarrTags.find(t => t.label == skipTag).get.id
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Replace unsafe .get with proper error handling.

Using .get on Option can throw NoSuchElementException if the tag is not found.

-        skipTagId = allRadarrTags.find(t => t.label == skipTag).get.id
+        skipTagId <- allRadarrTags.find(t => t.label == skipTag)
+          .toRight(new IllegalArgumentException(s"Skip tag '$skipTag' not found in Radarr"))
+          .map(_.id)
+          .toEitherT[IO]
📝 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
skipTagId = allRadarrTags.find(t => t.label == skipTag).get.id
skipTagId <- allRadarrTags.find(t => t.label == skipTag)
.toRight(new IllegalArgumentException(s"Skip tag '$skipTag' not found in Radarr"))
.map(_.id)
.toEitherT[IO]

} yield skipTagId
}

protected def hasSkipMovieTag(client: HttpClient)(
apiKey: String,
baseUrl: Uri,
skipTag: String,
tmdbId: Long
): EitherT[IO, Throwable, Boolean] = {
for {
skipTagId <- this.getSkipTagId(client)(apiKey, baseUrl, skipTag)
movies <- getToArrWithParams[List[RadarrMovie]](client)(baseUrl, apiKey, "movie", Map("tmdbId" -> tmdbId.toString))
} yield movies.head.tags.contains(skipTagId)
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Replace unsafe head with proper error handling.

Using head on List can throw NoSuchElementException if the list is empty.

-    } yield movies.head.tags.contains(skipTagId)
+    } yield movies.headOption.map(_.tags.contains(skipTagId))
+      .getOrElse(false)
📝 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
} yield movies.head.tags.contains(skipTagId)
} yield movies.headOption.map(_.tags.contains(skipTagId))
.getOrElse(false)

}

private def getToArr[T: Decoder](
client: HttpClient
)(baseUrl: Uri, apiKey: String, endpoint: String): EitherT[IO, Throwable, T] =
)(baseUrl: Uri, apiKey: String, endpoint: String): EitherT[IO, Throwable, T] = getToArrWithParams(client)(baseUrl, apiKey, endpoint, null)
Comment on lines 93 to +95
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Replace null with Option for params.

Using null is discouraged in Scala. Consider using Option instead.

-  )(baseUrl: Uri, apiKey: String, endpoint: String): EitherT[IO, Throwable, T] = getToArrWithParams(client)(baseUrl, apiKey, endpoint, null)
+  )(baseUrl: Uri, apiKey: String, endpoint: String): EitherT[IO, Throwable, T] = 
+    getToArrWithParams(client)(baseUrl, apiKey, endpoint, Map.empty)
📝 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
private def getToArr[T: Decoder](
client: HttpClient
)(baseUrl: Uri, apiKey: String, endpoint: String): EitherT[IO, Throwable, T] =
)(baseUrl: Uri, apiKey: String, endpoint: String): EitherT[IO, Throwable, T] = getToArrWithParams(client)(baseUrl, apiKey, endpoint, null)
private def getToArr[T: Decoder](
client: HttpClient
)(baseUrl: Uri, apiKey: String, endpoint: String): EitherT[IO, Throwable, T] =
getToArrWithParams(client)(baseUrl, apiKey, endpoint, Map.empty)


private def getToArrWithParams[T: Decoder](
client: HttpClient
)(baseUrl: Uri, apiKey: String, endpoint: String, params: Map[String, String]): EitherT[IO, Throwable, T] = {
var uri = baseUrl / "api" / "v3" / endpoint
if (params != null) {
params.foreachEntry((k, v) => uri = uri.withQueryParam(k, v))
}
Comment on lines +101 to +103
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Replace null check with Option handling.

Using null checks is discouraged in Scala.

-    if (params != null) {
-      params.foreachEntry((k, v) => uri = uri.withQueryParam(k, v))
-    }
+    params.foreachEntry((k, v) => uri = uri.withQueryParam(k, v))

Committable suggestion skipped: line range outside the PR's diff.

for {
response <- EitherT(client.httpRequest(Method.GET, baseUrl / "api" / "v3" / endpoint, Some(apiKey)))
response <- EitherT(client.httpRequest(Method.GET, uri, Some(apiKey)))
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 postToArr[T: Decoder](
client: HttpClient
Expand Down
3 changes: 2 additions & 1 deletion src/test/scala/PlexTokenSyncSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ class PlexTokenSyncSpec extends AnyFlatSpec with Matchers with MockFactory {
radarrQualityProfileId = 1,
radarrRootFolder = "/root/",
radarrBypassIgnored = false,
radarrTagIds = Set(2)
radarrTagIds = Set(2),
"keep"
Comment on lines +51 to +52
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Fix RadarrConfiguration constructor parameter order.

The radarrSkipTag parameter is incorrectly placed after radarrTagIds.

-      radarrTagIds = Set(2),
-      "keep"
+      radarrTagIds = Set(2),
+      radarrSkipTag = "keep"
📝 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
radarrTagIds = Set(2),
"keep"
radarrTagIds = Set(2),
radarrSkipTag = "keep"

),
PlexConfiguration(
plexWatchlistUrls = Set(),
Expand Down