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

refactor classes, make Plex, Sonarr and Radarr modular #34

Merged
merged 2 commits into from
Nov 16, 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
2 changes: 1 addition & 1 deletion src/main/scala/Server.scala
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@

import cats.effect._
import configuration.{Configuration, ConfigurationUtils, SystemPropertyReader}
import http.HttpClient
import org.slf4j.LoggerFactory
import utils.HttpClient

import java.nio.channels.ClosedChannelException

Expand Down
148 changes: 13 additions & 135 deletions src/main/scala/WatchlistSync.scala
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import cats.effect.IO
import org.http4s.{Method, Uri}
import cats.implicits._
import configuration.Configuration
import io.circe.generic.auto._
import io.circe.syntax._
import model._
import http.HttpClient
import model.Item
import org.slf4j.LoggerFactory
import utils.{ArrUtils, HttpClient}
import plex.PlexUtils
import radarr.RadarrUtils
import sonarr.SonarrUtils

object WatchlistSync {
object WatchlistSync
extends SonarrUtils with RadarrUtils with PlexUtils {

private val logger = LoggerFactory.getLogger(getClass)

Expand All @@ -17,100 +18,18 @@ object WatchlistSync {
logger.debug("Starting watchlist sync")

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

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

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(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
}
}
} yield movies ++ exclusions.map(_.toRadarrMovie)

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
}
}
} 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)

allIds.collect {
case Some(x) => x.toString
}.toSet
}

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

(watchlistIds.exists(allIds.contains), watchlistedItem.category) match {
(existingItems.exists(_.matches(watchlistedItem)), watchlistedItem.category) match {
case (true, c) =>
logger.debug(s"$c \"${watchlistedItem.title}\" already exists in Sonarr/Radarr")
IO.unit
Expand All @@ -126,45 +45,4 @@ object WatchlistSync {
}
}.toList.sequence.map(_.toSet)

private def cleanId: String => String = _.split("://").last

private case class RadarrPost(title: String, tmdbId: Long, qualityProfileId: Int = 6, rootFolderPath: String, addOptions: AddOptions = AddOptions())

private case class AddOptions(searchForMovie: Boolean = true)

private def findTmdbId(strings: List[String]): Option[Long] =
strings.find(_.startsWith("tmdb://")).flatMap(_.stripPrefix("tmdb://").toLongOption)

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(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")
}
}

private case class SonarrPost(title: String, tvdbId: Long, qualityProfileId: Int, rootFolderPath: String, addOptions: SonarrAddOptions, monitored: Boolean = true)

private case class SonarrAddOptions(monitor: String, searchForCutoffUnmetEpisodes: Boolean = true, searchForMissingEpisodes: Boolean = true)

private def findTvdbId(strings: List[String]): Option[Long] =
strings.find(_.startsWith("tvdb://")).flatMap(_.stripPrefix("tvdb://").toLongOption)

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

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

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) =>
logger.info(s"Failed to add show ${item.title}: $err")
}
}

}
161 changes: 1 addition & 160 deletions src/main/scala/configuration/Configuration.scala
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
package configuration

import cats.effect.IO
import io.circe.generic.auto._
import model.{QualityProfile, RootFolder}
import org.http4s.Uri
import org.slf4j.LoggerFactory
import utils.{ArrUtils, HttpClient}

import scala.concurrent.duration._
import scala.concurrent.duration.FiniteDuration

case class Configuration(
refreshInterval: FiniteDuration,
Expand All @@ -24,157 +19,3 @@ case class Configuration(
radarrBypassIgnored: Boolean,
plexWatchlistUrls: List[Uri]
)

object ConfigurationUtils {

private val logger = LoggerFactory.getLogger(getClass)

def create(configReader: ConfigurationReader, client: HttpClient): IO[Configuration] =
for {
sonarrConfig <- getSonarrConfig(configReader, client)
refreshInterval = configReader.getConfigOption(Keys.intervalSeconds).flatMap(_.toIntOption).getOrElse(60).seconds
(sonarrBaseUrl, sonarrApiKey, sonarrQualityProfileId, sonarrRootFolder) = sonarrConfig
sonarrBypassIgnored = configReader.getConfigOption(Keys.sonarrBypassIgnored).exists(_.toBoolean)
sonarrSeasonMonitoring = configReader.getConfigOption(Keys.sonarrSeasonMonitoring).getOrElse("all")
radarrConfig <- getRadarrConfig(configReader, client)
(radarrBaseUrl, radarrApiKey, radarrQualityProfileId, radarrRootFolder) = radarrConfig
radarrBypassIgnored = configReader.getConfigOption(Keys.radarrBypassIgnored).exists(_.toBoolean)
plexWatchlistUrls = getPlexWatchlistUrls(configReader)
} yield Configuration(
refreshInterval,
sonarrBaseUrl,
sonarrApiKey,
sonarrQualityProfileId,
sonarrRootFolder,
sonarrBypassIgnored,
sonarrSeasonMonitoring,
radarrBaseUrl,
radarrApiKey,
radarrQualityProfileId,
radarrRootFolder,
radarrBypassIgnored,
plexWatchlistUrls
)

private def getSonarrConfig(configReader: ConfigurationReader, client: HttpClient): IO[(Uri, String, Int, String)] = {
val url = configReader.getConfigOption(Keys.sonarrBaseUrl).flatMap(Uri.fromString(_).toOption).getOrElse {
val default = "http://localhost:8989"
logger.warn(s"Unable to fetch sonarr baseUrl, using default $default")
Uri.unsafeFromString(default)
}
val apiKey = configReader.getConfigOption(Keys.sonarrApiKey).getOrElse(throwError("Unable to find sonarr API key"))

ArrUtils.getToArr(client)(url, apiKey, "rootFolder").map {
case Right(res) =>
logger.info("Successfully connected to Sonarr")
val allRootFolders = res.as[List[RootFolder]].getOrElse(List.empty)
selectRootFolder(allRootFolders, configReader.getConfigOption(Keys.sonarrRootFolder))
case Left(err) =>
throwError(s"Unable to connect to Sonarr at $url, with error $err")
}.flatMap(rootFolder =>
ArrUtils.getToArr(client)(url, apiKey, "qualityprofile").map {
case Right(res) =>
val allQualityProfiles = res.as[List[QualityProfile]].getOrElse(List.empty)
val chosenQualityProfile = configReader.getConfigOption(Keys.sonarrQualityProfile)
(url, apiKey, getQualityProfileId(allQualityProfiles, chosenQualityProfile), rootFolder)
case Left(err) =>
throwError(s"Unable to connect to Sonarr at $url, with error $err")
}
)
}

private def getRadarrConfig(configReader: ConfigurationReader, client: HttpClient): IO[(Uri, String, Int, String)] = {
val url = configReader.getConfigOption(Keys.radarrBaseUrl).flatMap(Uri.fromString(_).toOption).getOrElse {
val default = "http://localhost:7878"
logger.warn(s"Unable to fetch radarr baseUrl, using default $default")
Uri.unsafeFromString(default)
}
val apiKey = configReader.getConfigOption(Keys.radarrApiKey).getOrElse(throwError("Unable to find radarr API key"))

ArrUtils.getToArr(client)(url, apiKey, "rootFolder").map {
case Right(res) =>
logger.info("Successfully connected to Radarr")
val allRootFolders = res.as[List[RootFolder]].getOrElse(List.empty)
selectRootFolder(allRootFolders, configReader.getConfigOption(Keys.radarrRootFolder))
case Left(err) =>
throwError(s"Unable to connect to Radarr at $url, with error $err")
}.flatMap(rootFolder =>
ArrUtils.getToArr(client)(url, apiKey, "qualityprofile").map {
case Right(res) =>
val allQualityProfiles = res.as[List[QualityProfile]].getOrElse(List.empty)
val chosenQualityProfile = configReader.getConfigOption(Keys.radarrQualityProfile)
(url, apiKey, getQualityProfileId(allQualityProfiles, chosenQualityProfile), rootFolder)
case Left(err) =>
throwError(s"Unable to connect to Radarr at $url, with error $err")
}
)
}

private def getQualityProfileId(allProfiles: List[QualityProfile], maybeEnvVariable: Option[String]): Int =
(allProfiles, maybeEnvVariable) match {
case (Nil, _) =>
throwError("Could not find any quality profiles defined, check your Sonarr/Radarr settings")
case (List(one), _) =>
logger.debug(s"Only one quality profile defined: ${one.name}")
one.id
case (_, None) =>
logger.debug("Multiple quality profiles found, selecting the first one in the list.")
allProfiles.head.id
case (_, Some(profileName)) =>
allProfiles.find(_.name.toLowerCase == profileName.toLowerCase).map(_.id).getOrElse(
throwError(s"Unable to find quality profile $profileName. Possible values are $allProfiles")
)
}

private def selectRootFolder(allRootFolders: List[RootFolder], maybeEnvVariable: Option[String]): String =
(allRootFolders, maybeEnvVariable) match {
case (Nil, _) =>
throwError("Could not find any root folders, check your Sonarr/Radarr settings")
case (_, Some(path)) =>
allRootFolders.filter(_.accessible).find(r => normalizePath(r.path) == normalizePath(path)).map(_.path).getOrElse(
throwError(s"Unable to find root folder $path. Possible values are ${allRootFolders.filter(_.accessible).map(_.path)}")
)
case (_, None) =>
allRootFolders.find(_.accessible).map(_.path).getOrElse(
throwError("Found root folders, but they are not accessible by Sonarr/Radarr")
)
}

private def normalizePath(path: String): String = if (path.endsWith("/") && path.length > 1) path.dropRight(1) else path

private def getPlexWatchlistUrls(configReader: ConfigurationReader): List[Uri] =
Set(
configReader.getConfigOption(Keys.plexWatchlist1),
configReader.getConfigOption(Keys.plexWatchlist2)
).toList.collect {
case Some(url) => url
} match {
case Nil =>
throwError("Missing plex watchlist URL")
case other => other.map(toPlexUri)
}

private def toPlexUri(url: String): Uri = {
val supportedHosts = List(
"rss.plex.tv"
).map(Uri.Host.unsafeFromString)

val rawUri = Uri.fromString(url).getOrElse(
throwError(s"Plex watchlist $url is not a valid uri")
)

val host = rawUri.host.getOrElse(
throwError(s"Plex watchlist host not found in $rawUri")
)

if (!supportedHosts.contains(host))
throwError(s"Unsupported Uri host on watchlist: ${rawUri.host}. Accepted hosts: $supportedHosts")

rawUri
}

private def throwError(message: String): Nothing = {
logger.error(message)
throw new IllegalArgumentException(message)
}
}
4 changes: 0 additions & 4 deletions src/main/scala/configuration/ConfigurationReader.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,3 @@ package configuration
trait ConfigurationReader {
def getConfigOption(key: String): Option[String]
}

object SystemPropertyReader extends ConfigurationReader {
def getConfigOption(key: String): Option[String] = Option(System.getProperty(key))
}
Loading
Loading