Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Better error handling #39

Merged
merged 1 commit into from
Nov 19, 2023
Merged
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
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
Loading