Skip to content

Commit

Permalink
Better error handling (#39)
Browse files Browse the repository at this point in the history
  • Loading branch information
nylonee authored Nov 19, 2023
1 parent 6c0e41a commit 19e84a3
Show file tree
Hide file tree
Showing 9 changed files with 38,746 additions and 87 deletions.
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@

<img src="watchlistarr.png" alt="Alternate Text" width="240"/>

Sync plex watchlists in realtime with Sonarr and Radarr. **Requires Plex Pass**

**Requires Sonarr v4**
Sync plex watchlists in realtime with Sonarr and Radarr.

### Requirements
* Plex Pass Subscription for main user
* Sonarr v4 or higher
* Radarr v3 or higher
* Friends must change privacy settings so that the main user can see their watchlists
* Docker or Java

## Why?

Expand Down
34 changes: 23 additions & 11 deletions src/main/scala/WatchlistSync.scala
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import cats.data.EitherT
import cats.effect.IO
import cats.implicits._
import configuration.Configuration
Expand All @@ -17,32 +18,43 @@ object WatchlistSync

logger.debug("Starting watchlist sync")

for {
watchlistDatas <- config.plexWatchlistUrls.map(fetchWatchlistFromRss(client)).sequence
val result = for {
watchlistDatas <- EitherT[IO, Throwable, List[Set[Item]]](config.plexWatchlistUrls.map(fetchWatchlistFromRss(client)).sequence.map(Right(_)))
watchlistData = watchlistDatas.flatten.toSet
movies <- fetchMovies(client)(config.radarrApiKey, config.radarrBaseUrl, config.radarrBypassIgnored)
series <- fetchSeries(client)(config.sonarrApiKey, config.sonarrBaseUrl, config.sonarrBypassIgnored)
allIds = movies ++ series
_ <- missingIds(client)(config)(allIds, watchlistData)
} yield ()

result.value.map {
case Left(err) =>
logger.warn(s"An error occured while attempting to sync: $err")
case Right(_) =>
logger.debug("Watchlist sync complete")
}
}

private def missingIds(client: HttpClient)(config: Configuration)(existingItems: Set[Item], watchlist: Set[Item]): IO[Set[Unit]] =
watchlist.map { watchlistedItem =>
(existingItems.exists(_.matches(watchlistedItem)), watchlistedItem.category) match {
private def missingIds(client: HttpClient)(config: Configuration)(existingItems: Set[Item], watchlist: Set[Item]): EitherT[IO, Throwable, Set[Unit]] = {
for {
watchlistedItem <- watchlist
maybeExistingItem = existingItems.exists(_.matches(watchlistedItem))
category = watchlistedItem.category
task = EitherT.fromEither[IO]((maybeExistingItem, category) match {
case (true, c) =>
logger.debug(s"$c \"${watchlistedItem.title}\" already exists in Sonarr/Radarr")
IO.unit
Right(IO.unit)
case (false, "show") =>
logger.debug(s"Found show \"${watchlistedItem.title}\" which does not exist yet in Sonarr")
addToSonarr(client)(config)(watchlistedItem)
Right(addToSonarr(client)(config)(watchlistedItem))
case (false, "movie") =>
logger.debug(s"Found movie \"${watchlistedItem.title}\" which does not exist yet in Radarr")
addToRadarr(client)(config)(watchlistedItem)
Right(addToRadarr(client)(config)(watchlistedItem))
case (false, c) =>
logger.warn(s"Found $c \"${watchlistedItem.title}\", but I don't recognize the category")
IO.unit
}
}.toList.sequence.map(_.toSet)
Left(new Throwable(s"Unknown category $c"))
})
} yield task.flatMap(EitherT.liftF[IO, Throwable, Unit])
}.toList.sequence.map(_.toSet)

}
55 changes: 21 additions & 34 deletions src/main/scala/radarr/RadarrUtils.scala
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
package radarr

import cats.data.EitherT
import cats.effect.IO
import configuration.Configuration
import http.HttpClient
import io.circe.Json
import io.circe.{Decoder, Json}
import io.circe.generic.auto._
import io.circe.syntax.EncoderOps
import model.Item
Expand All @@ -14,49 +15,35 @@ trait RadarrUtils extends RadarrConversions {

private val logger = LoggerFactory.getLogger(getClass)

protected def fetchMovies(client: HttpClient)(apiKey: String, baseUrl: Uri, bypass: Boolean): IO[Set[Item]] =
protected def fetchMovies(client: HttpClient)(apiKey: String, baseUrl: Uri, bypass: Boolean): EitherT[IO, Throwable, Set[Item]] =
for {
movies <- getToArr(client)(baseUrl, apiKey, "movie").map {
case Right(res) =>
res.as[List[RadarrMovie]].getOrElse {
logger.warn("Unable to fetch movies from Radarr - decoding failure. Returning empty list instead")
List.empty
}
case Left(err) =>
logger.warn(s"Received error while trying to fetch movies from Radarr: $err")
List.empty
}
movies <- getToArr[List[RadarrMovie]](client)(baseUrl, apiKey, "movie")
exclusions <- if (bypass) {
IO.pure(List.empty)
EitherT.pure[IO, Throwable](List.empty[RadarrMovieExclusion])
} else {
getToArr(client)(baseUrl, apiKey, "exclusions").map {
case Right(res) =>
res.as[List[RadarrMovieExclusion]].getOrElse {
logger.warn("Unable to fetch movie exclusions from Radarr - decoding failure. Returning empty list instead")
List.empty
}
case Left(err) =>
logger.warn(s"Received error while trying to fetch movie exclusions from Radarr: $err")
List.empty
}
getToArr[List[RadarrMovieExclusion]](client)(baseUrl, apiKey, "exclusions")
}
} yield (movies.map(toItem) ++ exclusions.map(toItem)).toSet

protected def addToRadarr(client: HttpClient)(config: Configuration)(item: Item): IO[Unit] = {

val movie = RadarrPost(item.title, item.getTmdbId.getOrElse(0L), config.radarrQualityProfileId, config.radarrRootFolder)

postToArr(client)(config.radarrBaseUrl, config.radarrApiKey, "movie")(movie.asJson).map {
case Right(_) =>
logger.info(s"Successfully added movie ${item.title} to Radarr")
case Left(err) =>
logger.error(s"Failed to add movie ${item.title}: $err")
}
postToArr[Unit](client)(config.radarrBaseUrl, config.radarrApiKey, "movie")(movie.asJson).getOrElse(
logger.warn(s"Unable to send ${item.title} to Radarr")
)
}

private def getToArr(client: HttpClient)(baseUrl: Uri, apiKey: String, endpoint: String): IO[Either[Throwable, Json]] =
client.httpRequest(Method.GET, baseUrl / "api" / "v3" / endpoint, Some(apiKey))
private def getToArr[T: Decoder](client: HttpClient)(baseUrl: Uri, apiKey: String, endpoint: String): EitherT[IO, Throwable, T] =
for {
response <- EitherT(client.httpRequest(Method.GET, baseUrl / "api" / "v3" / endpoint, 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(client: HttpClient)(baseUrl: Uri, apiKey: String, endpoint: String)(payload: Json): IO[Either[Throwable, Json]] =
client.httpRequest(Method.POST, baseUrl / "api" / "v3" / endpoint, Some(apiKey), Some(payload))
private def postToArr[T: Decoder](client: HttpClient)(baseUrl: Uri, apiKey: String, endpoint: String)(payload: Json): EitherT[IO, Throwable, T] =
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
}
54 changes: 21 additions & 33 deletions src/main/scala/sonarr/SonarrUtils.scala
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
package sonarr

import cats.data.EitherT
import cats.effect.IO
import configuration.Configuration
import http.HttpClient
import io.circe.Json
import io.circe.{Decoder, Json}
import io.circe.generic.auto._
import io.circe.syntax.EncoderOps
import model.Item
Expand All @@ -14,31 +15,13 @@ trait SonarrUtils extends SonarrConversions {

private val logger = LoggerFactory.getLogger(getClass)

protected def fetchSeries(client: HttpClient)(apiKey: String, baseUrl: Uri, bypass: Boolean): IO[Set[Item]] =
protected def fetchSeries(client: HttpClient)(apiKey: String, baseUrl: Uri, bypass: Boolean): EitherT[IO, Throwable, Set[Item]] =
for {
shows <- getToArr(client)(baseUrl, apiKey, "series").map {
case Right(res) =>
res.as[List[SonarrSeries]].getOrElse {
logger.warn("Unable to fetch series from Sonarr - decoding failure. Returning empty list instead")
List.empty
}
case Left(err) =>
logger.warn(s"Received error while trying to fetch movies from Radarr: $err")
List.empty
}
shows <- getToArr[List[SonarrSeries]](client)(baseUrl, apiKey, "series")
exclusions <- if (bypass) {
IO.pure(List.empty)
EitherT.pure[IO, Throwable](List.empty[SonarrSeries])
} else {
getToArr(client)(baseUrl, apiKey, "importlistexclusion").map {
case Right(res) =>
res.as[List[SonarrSeries]].getOrElse {
logger.warn("Unable to fetch show exclusions from Sonarr - decoding failure. Returning empty list instead")
List.empty
}
case Left(err) =>
logger.warn(s"Received error while trying to fetch show exclusions from Sonarr: $err")
List.empty
}
getToArr[List[SonarrSeries]](client)(baseUrl, apiKey, "importlistexclusion")
}
} yield (shows.map(toItem) ++ exclusions.map(toItem)).toSet

Expand All @@ -47,17 +30,22 @@ trait SonarrUtils extends SonarrConversions {
val addOptions = SonarrAddOptions(config.sonarrSeasonMonitoring)
val show = SonarrPost(item.title, item.getTvdbId.getOrElse(0L), config.sonarrQualityProfileId, config.radarrRootFolder, addOptions)

postToArr(client)(config.sonarrBaseUrl, config.sonarrApiKey, "series")(show.asJson).map {
case Right(_) =>
logger.info(s"Successfully added show ${item.title} to Sonarr")
case Left(err) =>
logger.info(s"Failed to add show ${item.title}: $err")
}
postToArr[Unit](client)(config.sonarrBaseUrl, config.sonarrApiKey, "series")(show.asJson).getOrElse(
logger.warn(s"Unable to send ${item.title} to Sonarr")
)
}

private def getToArr(client: HttpClient)(baseUrl: Uri, apiKey: String, endpoint: String): IO[Either[Throwable, Json]] =
client.httpRequest(Method.GET, baseUrl / "api" / "v3" / endpoint, Some(apiKey))
private def getToArr[T: Decoder](client: HttpClient)(baseUrl: Uri, apiKey: String, endpoint: String): EitherT[IO, Throwable, T] =
for {
response <- EitherT(client.httpRequest(Method.GET, baseUrl / "api" / "v3" / endpoint, Some(apiKey)))
maybeDecoded <- EitherT.pure[IO, Throwable](response.as[T])
decoded <- EitherT.fromOption[IO](maybeDecoded.toOption, new Throwable("Unable to decode response from Sonarr"))
} yield decoded

private def postToArr(client: HttpClient)(baseUrl: Uri, apiKey: String, endpoint: String)(payload: Json): IO[Either[Throwable, Json]] =
client.httpRequest(Method.POST, baseUrl / "api" / "v3" / endpoint, Some(apiKey), Some(payload))
private def postToArr[T: Decoder](client: HttpClient)(baseUrl: Uri, apiKey: String, endpoint: String)(payload: Json): EitherT[IO, Throwable, T] =
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
}
Loading

0 comments on commit 19e84a3

Please sign in to comment.