Skip to content

Commit

Permalink
Merge develop into main (#13)
Browse files Browse the repository at this point in the history
* Improve type safety by providing URIs instead of string, plus tests (#11)

* Skip items that have an -arr exclusion. Closes #10 (#12)

* Fix failing test
  • Loading branch information
nylonee authored Oct 29, 2023
1 parent a7b4fd4 commit b3995f0
Show file tree
Hide file tree
Showing 14 changed files with 412 additions and 173 deletions.
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@

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

## Why?
### Sonarr/Radarr limitation
While Sonarr and Radarr have built-in functionality (in v4 and v3 respectively), they are set at 6 hour refresh intervals, and cannot be customized

### Ombi/Overseer limitation
While Ombi and Overseer have built-in functionality, there are two problems with this:
* They are customizable down to 5 minute intervals, so doesn't allow the "real-time" sync that Watchlistarr does
* They rely on Plex tokens, which expire and break the sync if you're not regularly logging into Ombi/Overseer

## Getting Started

The easiest way to try this code is using docker:
Expand All @@ -23,10 +32,12 @@ docker run \
| SONARR_BASE_URL | http://localhost:8989 | Yes | Base URL for Sonarr, including the 'http' and port |
| SONARR_QUALITY_PROFILE | 1080p | Yes | Quality profile for Sonarr, found in your Sonarr UI -> Profiles settings |
| SONARR_ROOT_FOLDER | /data/ | Yes | Root folder for Sonarr |
| SONARR_BYPASS_IGNORED | true | Yes | Boolean flag to bypass tv shows that are on the Sonarr Exclusion List |
| RADARR_API_KEY | 7a392fb4817a46e59f2e84e7d5f021bc | No | API key for Radarr, found in your Radarr UI -> General settings |
| RADARR_BASE_URL | http://127.0.0.1:7878 | Yes | Base URL for Radarr, including the 'http' and port |
| RADARR_QUALITY_PROFILE | 1080p | Yes | Quality profile for Radarr, found in your Radarr UI -> Profiles settings |
| RADARR_ROOT_FOLDER | /data/ | Yes | Root folder for Radarr |
| RADARR_BYPASS_IGNORED | true | Yes | Boolean flag to bypass movies that are on the Radarr Exclusion List |
| PLEX_WATCHLIST_URL_1 | https://rss.plex.tv/UUID | No | First Plex Watchlist URL |
| PLEX_WATCHLIST_URL_2 | https://rss.plex.tv/UUID | Yes | Second Plex Watchlist URL (if applicable) |

Expand Down
4 changes: 3 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ val logbackVersion = "1.4.7"
val fs2Version = "3.7.0"
val circeVersion = "0.14.5"
val circeGenericExtrasVersion = "0.14.3"
val scalatestVersion = "3.2.15"
val slf4jVersion = "2.0.9"
val scalatestVersion = "3.2.15"
val scalamockVersion = "5.2.0"

libraryDependencies ++= Seq(
"org.scala-lang" % "scala-library" % scalaVersion.value % "provided",
Expand All @@ -29,6 +30,7 @@ libraryDependencies ++= Seq(
"io.circe" %% "circe-generic" % circeVersion,
"io.circe" %% "circe-parser" % circeVersion % Test,
"io.circe" %% "circe-generic-extras" % circeGenericExtrasVersion % Test,
"org.scalamock" %% "scalamock" % scalamockVersion % Test,
"org.scalatest" %% "scalatest" % scalatestVersion % Test
)

Expand Down
8 changes: 8 additions & 0 deletions docker/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,12 @@ if [ -n "$REFRESH_INTERVAL_SECONDS" ]; then
CMD="$CMD -Dinterval.seconds=$REFRESH_INTERVAL_SECONDS"
fi

if [ -n "$SONARR_BYPASS_IGNORED" ]; then
CMD="$CMD -Dsonarr.bypassIgnored=$SONARR_BYPASS_IGNORED"
fi

if [ -n "$RADARR_BYPASS_IGNORED" ]; then
CMD="$CMD -Dradarr.bypassIgnored=$RADARR_BYPASS_IGNORED"
fi

exec $CMD
34 changes: 0 additions & 34 deletions src/main/scala/ArrUtils.scala

This file was deleted.

95 changes: 0 additions & 95 deletions src/main/scala/Configuration.scala

This file was deleted.

6 changes: 5 additions & 1 deletion src/main/scala/Server.scala
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@

import cats.effect._
import configuration.{Configuration, SystemPropertyReader}
import utils.HttpClient

object Server extends IOApp {

val config = new Configuration
val configReader = SystemPropertyReader
val httpClient = new HttpClient()
val config = new Configuration(configReader, httpClient)

def run(args: List[String]): IO[ExitCode] = {

Expand Down
121 changes: 79 additions & 42 deletions src/main/scala/WatchlistSync.scala
Original file line number Diff line number Diff line change
@@ -1,65 +1,102 @@
import cats.effect.IO
import org.http4s.{Header, Method, Request, Uri}
import org.http4s.ember.client.EmberClientBuilder
import org.typelevel.ci.CIString
import org.http4s.{Method, Uri}
import cats.implicits._
import configuration.Configuration
import io.circe.generic.auto._
import org.http4s.circe.CirceEntityDecoder._
import io.circe.syntax._
import model._
import org.slf4j.LoggerFactory
import utils.{ArrUtils, HttpClient}

object WatchlistSync {

private val logger = LoggerFactory.getLogger(getClass)

def run(config: Configuration): IO[Unit] = {

logger.debug("Starting watchlist sync")

for {
watchlistDatas <- config.plexWatchlistUrls.map(fetchWatchlist).sequence
watchlistDatas <- config.plexWatchlistUrls.map(fetchWatchlist(config.client)).sequence
watchlistData = watchlistDatas.fold(Watchlist(Set.empty))(mergeWatchLists)
movies <- fetchMovies(config.radarrApiKey, config.radarrBaseUrl)
series <- fetchSeries(config.sonarrApiKey, config.sonarrBaseUrl)
movies <- fetchMovies(config.client)(config.radarrApiKey, config.radarrBaseUrl, config.radarrBypassIgnored)
series <- fetchSeries(config.client)(config.sonarrApiKey, config.sonarrBaseUrl, config.sonarrBypassIgnored)
allIds = merge(movies, series)
_ <- missingIds(config)(allIds, watchlistData.items)
_ <- missingIds(config.client)(config)(allIds, watchlistData.items)
} yield ()
}

private def mergeWatchLists(l: Watchlist, r: Watchlist): Watchlist = Watchlist(l.items ++ r.items)

private def fetchWatchlist(url: String): IO[Watchlist] = {
EmberClientBuilder.default[IO].build.use { client =>
val req = Request[IO](
method = Method.GET,
uri = Uri.unsafeFromString(url)
).withHeaders(Header.Raw(CIString("Accept"), "application/json"))

client.expect[Watchlist](req)
private def fetchWatchlist(client: HttpClient)(url: Uri): IO[Watchlist] =
client.httpRequest(Method.GET, url).map {
case Left(err) =>
logger.warn(s"Unable to fetch watchlist from Plex: $err")
Watchlist(Set.empty)
case Right(json) =>
logger.debug("Found Json from Plex watchlist, attempting to decode")
json.as[Watchlist].getOrElse {
logger.warn("Unable to fetch watchlist from Plex - decoding failure. Returning empty list instead")
Watchlist(Set.empty)
}
}
}

private def fetchMovies(apiKey: String, baseUrl: String): IO[List[RadarrMovie]] =
ArrUtils.getToArr(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")
private def fetchMovies(client: HttpClient)(apiKey: String, baseUrl: Uri, bypass: Boolean): IO[List[RadarrMovie]] =
for {
movies <- ArrUtils.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
}
exclusions <- if (bypass) {
IO.pure(List.empty)
} else {
ArrUtils.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
}
case Left(err) =>
logger.warn(s"Received error while trying to fetch movies from Radarr: $err")
throw err
}
}
} yield movies ++ exclusions.map(_.toRadarrMovie)

private def fetchSeries(apiKey: String, baseUrl: String): IO[List[SonarrSeries]] =
ArrUtils.getToArr(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")
private def fetchSeries(client: HttpClient)(apiKey: String, baseUrl: Uri, bypass: Boolean): IO[List[SonarrSeries]] =
for {
shows <- ArrUtils.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
}
exclusions <- if (bypass) {
IO.pure(List.empty)
} else {
ArrUtils.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
}
case Left(err) =>
logger.warn(s"Received error while trying to fetch movies from Radarr: $err")
throw err
}
}
} yield shows ++ exclusions


private def merge(r: List[RadarrMovie], s: List[SonarrSeries]): Set[String] = {
val allIds = r.map(_.imdbId) ++ r.map(_.tmdbId) ++ s.map(_.imdbId) ++ s.map(_.tvdbId)
Expand All @@ -69,7 +106,7 @@ object WatchlistSync {
}.toSet
}

private def missingIds(config: Configuration)(allIds: Set[String], watchlist: Set[Item]): IO[Set[Unit]] =
private def missingIds(client: HttpClient)(config: Configuration)(allIds: Set[String], watchlist: Set[Item]): IO[Set[Unit]] =
watchlist.map { watchlistedItem =>
val watchlistIds = watchlistedItem.guids.map(cleanId).toSet

Expand All @@ -79,10 +116,10 @@ object WatchlistSync {
IO.unit
case (false, "show") =>
logger.debug(s"Found show \"${watchlistedItem.title}\" which does not exist yet in Sonarr")
addToSonarr(config)(watchlistedItem)
addToSonarr(client)(config)(watchlistedItem)
case (false, "movie") =>
logger.debug(s"Found movie \"${watchlistedItem.title}\" which does not exist yet in Radarr")
addToRadarr(config)(watchlistedItem)
addToRadarr(client)(config)(watchlistedItem)
case (false, c) =>
logger.warn(s"Found $c \"${watchlistedItem.title}\", but I don't recognize the category")
IO.unit
Expand All @@ -98,11 +135,11 @@ object WatchlistSync {
private def findTmdbId(strings: List[String]): Option[Long] =
strings.find(_.startsWith("tmdb://")).flatMap(_.stripPrefix("tmdb://").toLongOption)

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

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

ArrUtils.postToArr(config.radarrBaseUrl, config.radarrApiKey, "movie")(movie.asJson).map {
ArrUtils.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) =>
Expand All @@ -117,11 +154,11 @@ object WatchlistSync {
private def findTvdbId(strings: List[String]): Option[Long] =
strings.find(_.startsWith("tvdb://")).flatMap(_.stripPrefix("tvdb://").toLongOption)

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

val show = SonarrPost(item.title, findTvdbId(item.guids).getOrElse(0L), config.sonarrQualityProfileId, config.sonarrRootFolder)

ArrUtils.postToArr(config.sonarrBaseUrl, config.sonarrApiKey, "series")(show.asJson).map {
ArrUtils.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) =>
Expand Down
Loading

0 comments on commit b3995f0

Please sign in to comment.